@erclx/canon 4.55.0 → 4.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.55.0",
4
+ "version": "4.56.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -56,6 +56,13 @@ Run `canon tooling sync <stack> <target> --check` for the list resolved against
56
56
  - `commitlint.config.js`
57
57
  - `scripts/verify.sh`
58
58
 
59
+ ### nextjs
60
+
61
+ - `eslint.config.js`
62
+ - `next.config.ts`
63
+ - `playwright.config.ts`
64
+ - `vitest.config.ts`
65
+
59
66
  ### python
60
67
 
61
68
  - `.coveragerc`
@@ -35,12 +35,28 @@ Invoke `canon:claude-worker` first, whatever the worktree state. This session is
35
35
 
36
36
  Report it rather than proceeding silently when `canon:claude-worker` does not resolve. It ships with the plugin, so a session running this chain from a project holding the CLI alone builds with no role asserted.
37
37
 
38
- If `git rev-parse --git-dir` equals `git rev-parse --git-common-dir`, the session is in the main worktree. Invoke `canon:claude-worktree` before continuing. The wrapper handles name derivation and branch alignment. Do not call `EnterWorktree` directly.
38
+ If `git rev-parse --git-dir` equals `git rev-parse --git-common-dir`, the session is in the main worktree. Invoke `canon:claude-worktree` before continuing, carrying the argument the subsection below derives. The wrapper handles branch alignment. Do not call `EnterWorktree` directly.
39
39
 
40
40
  If neither command resolves, stop: `❌ Not a git repository. Autoship needs git or a WorktreeCreate hook.`
41
41
 
42
42
  If the two commands differ, the session is already in a linked worktree. Continue.
43
43
 
44
+ ### Name the worktree from the plan rather than leaving it to be derived
45
+
46
+ This step runs ahead of Step 1, so what it holds is the raw invocation argument rather than a resolved plan. When that argument is a plan path or a bare slug, run the verb on it and hand the result to `canon:claude-worktree` as its tier 0 argument:
47
+
48
+ ```bash
49
+ canon tasks plan-branch <argument> --json
50
+ ```
51
+
52
+ - `conforms: true`: pass the record's `branch`, which is already `<type>/<slug>`, and invoke nothing else to derive a name.
53
+ - `conforms: false`: the plan's own filename breaks a cap in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. Pass `branch` anyway and say the cap it broke, since the alternative is a name this session shortened by hand, which is a second derivation and the thing this call exists to prevent. `git-branch` decides the rename at ship.
54
+ - Anything else, including a refusal, a record carrying no `branch` key, and an installed binary carrying no `plan-branch` subcommand: invoke `canon:claude-worktree` bare and let its ladder derive the name. Say the verb did not answer, so a reader can tell a derived name from a fallback one.
55
+
56
+ The dispatch runbook runs the same verb on the same plan to pick the branch its collision check clears, so calling it here is what makes the checked branch and the taken branch one string. Deriving a name from `<plan>` by reading it was the alternative, and it is what produced three strings for one plan across four dispatches on 2026-09-05.
57
+
58
+ A caller that supplied a task path, or supplied nothing at all, has no plan to hand the verb here, since resolving either is Step 1's work and Step 1 has not run. Invoke `canon:claude-worktree` bare in both cases. That is the ladder unchanged rather than a regression, and it leaves the hole open: a dispatched worker reaching this step through a task path derives its name from a tier rather than from the plan the dispatcher checked.
59
+
44
60
  ## Step 1: read the plan
45
61
 
46
62
  Resolve `<plan>` in this order, stopping at the first match:
@@ -7,9 +7,18 @@ Run this at loop step 4, for a `## Run now` row whose plan is verified, in place
7
7
 
8
8
  ## Derive the candidate
9
9
 
10
- Resolve `<slug>` from `<plan>`, the row's plan file, the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
10
+ Run `canon tasks plan-branch <plan> --json` against the row's plan file and read `branch`, `type`, `slug`, and `conforms` off the record.
11
11
 
12
- This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and a slug mismatch no longer fails the run downstream on its own, since `claude-autoship` now takes `<plan>` directly rather than resolving it from the worker's own branch. The check above is what has to catch a wrong candidate now.
12
+ - `conforms: true`: take `branch` as the candidate, and take `type` and `slug` from the same record for the check below.
13
+ - `conforms: false`: the plan's own filename breaks a cap in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. Report which, and hand the row to the human-launch line below rather than shortening the slug here. A rename parts the branch from the plan filename that `claude-worktree` tier 1 and `git-pr`'s plan lookup both read back.
14
+ - `reason: archived`, `no-plan`, or `bad-input`: the row does not cite a live plan. Repoint the row or fix the citation rather than dispatching, since `claude-autoship` Step 1 refuses the same file and the worker would meet that refusal after the launch spent.
15
+ - Anything else, including a record carrying no `branch` key and an installed binary carrying no `plan-branch` subcommand: treat the candidate as unverified rather than clear, name which reading could not be taken, and fall back to the human-launch line below. Re-deriving by prose here rebuilds the defect the verb closes, and does it quietly.
16
+
17
+ Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
18
+
19
+ The worker calls the same verb on the same plan at `claude-autoship` Step 0, so the branch this gate checks and the branch that session takes are one string by construction rather than two readings of one paragraph. They were two readings until 2026-09-06. One run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, another checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`, and four dispatches on 2026-09-05 produced three strings for one plan. A check against a branch nobody uses verifies nothing.
20
+
21
+ The type the verb reports is fixed at `feat` whatever the row does, which is the half of the derivation that disagreed most. What makes that safe is that a branch type is cosmetic: `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff, so nothing a release reads passes through the branch name. What it costs is a worktree listing where every dispatched branch reads `feat/`, which a person scanning one loses. Nothing renames it later, and this paragraph said `git-branch` did until 2026-09-06, when its conventions guard turned out to fire on a conforming `feat/` before reaching any type judgment.
13
22
 
14
23
  ## Check the plan waits on nobody
15
24
 
@@ -93,9 +102,9 @@ The worker resolves that id back to a name through `canon sessions list --json`,
93
102
 
94
103
  The template carries no worktree call. `claude-autoship` Step 0 invokes `canon:claude-worker` and then `canon:claude-worktree` itself, and neither carries the flag, so both are reachable through the `Skill` tool regardless of where a call to them would sit in a prompt. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
95
104
 
96
- Dropping the argument does not hand `claude-worktree` a formal one in its place. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there, so the ladder alone still closes nothing.
105
+ The template names no branch, and it does not need to. `claude-autoship` Step 0 runs `canon tasks plan-branch <plan>` on the same file this runbook derived the candidate from, and hands the `<type>/<slug>` it reports to `claude-worktree` as its tier 0 argument, so the two sides agree by calling one derivation rather than by a string copied between them.
97
106
 
98
- What closes it is the same inference four workers already took before this template existed: the session already holds `<plan>` and derives `claude-worktree`'s name from it directly, rather than waiting on a tier to supply one. That is a judgment rather than a contract, and it is the same judgment both live disagreements came from, so read it as the residual risk this template still carries rather than as solved.
107
+ That retires the inference four workers took before the verb existed, which was to derive the name from `<plan>` by their own reading of it. Nothing has to reach `claude-worktree`'s ladder now, which mattered because a worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. What still travels on judgment is the fallback: a worker whose installed binary carries no `plan-branch` derives by prose, which is where both live disagreements came from.
99
108
 
100
109
  ### Expansion needs position zero and a clean delimiter, not leading order alone
101
110
 
@@ -49,11 +49,17 @@ Tier 0 sits ahead of the inference because every tier below it answers from stat
49
49
 
50
50
  Validate the result: letters, digits, dots, underscores, dashes only, max 64 chars (`/` separators are also allowed). If the derived name violates the rule, sanitize by replacing invalid chars with `-` and truncating. Show the sanitized name in the preview before invoking.
51
51
 
52
- Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it, drawing the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. A type the caller spelled in tier 0 wins outright and no reading overrides it. A name from a plan takes the type that plan's own work carries, read off its `## Summary` and `**Files to touch:**` lines. Every other case takes `feat`, which covers a name from a branch, a bare name from the user, and a plan whose lines settle nothing.
52
+ Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it, drawing the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. A type the caller spelled in tier 0 wins outright and no reading overrides it. A name that came from a plan, through tier 1 or tier 2, takes its type from `canon tasks plan-branch <plan> --json`, read off the record's `type` field. Every other case takes `feat`, which covers a name from a branch, a bare name from the user, and a plan the verb could not answer for.
53
53
 
54
- The caller's type wins because reading it off a plan is the half that has already disagreed in production. One dispatch checked `fix/path-form-hook` and the worker took `feat/path-form-hook`, both sides reading the same plan and grading it differently.
54
+ The verb is the reading rather than this body, because a type read off a plan's `## Summary` and `**Files to touch:**` lines is a judgment, and it has disagreed with itself in production. One dispatch checked `fix/path-form-hook` and the worker took `feat/path-form-hook`, both sides reading the same plan and grading it differently. The verb answers `feat` for every plan, so two sides calling it cannot part.
55
55
 
56
- A wrong type is cheap. `git-branch` renames to conventional format later in the same chain and runs ahead of `git-pr`, so a `feat/` written over a fix is corrected before any pull request opens.
56
+ The caller's type still wins over the verb's, because tier 0 is the one source that knows something no file states. The ordinary caller is `claude-autoship` Step 0, which ran the verb itself and is handing over the answer it got, so nothing is overridden in that case either.
57
+
58
+ Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero. Take `feat` and say the verb did not answer where it refuses, where the record carries no `type` key, or where the installed binary carries no `plan-branch` subcommand.
59
+
60
+ A wrong type is cheap because the branch type is cosmetic rather than because anything corrects it. `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff too, so the semantics a release reads never pass through the branch name at all. Nothing downstream is wrong when a `feat/` sits over a fix, and a person scanning a worktree listing loses a signal.
61
+
62
+ `git-branch` does not correct it, which three surfaces used to say it did. Its second guard reads `If branch name already follows conventions, stop`, and `${CLAUDE_SKILL_DIR}/../../standards/branch.md` makes the type vocabulary an axis without making the choice within that vocabulary one, so `feat/` over a fix conforms and the guard fires ahead of the Analysis line that would have re-derived the type.
57
63
 
58
64
  Then test both names the entry is about to claim. Neither read needs a worktree, and a stop after Step 4 leaves one built with the session sitting inside it, so both belong here rather than beside the rename:
59
65
 
@@ -45,7 +45,8 @@ Read these from the project root in parallel, skipping any that do not exist:
45
45
  ## Resolve arguments
46
46
 
47
47
  - **Stack:** pick the closest governance stack by matching detected runtime or framework against stack names in the catalog. If nothing matches, fall back to `base` and carry the fallback into the preview.
48
- - **Tooling stack:** pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`). Distinct from the governance stack. Fall back to `base` if no framework match, and carry that fallback into the preview too.
48
+ - **Tooling stack:** pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`, `nextjs`). Distinct from the governance stack. Fall back to `base` if no framework match, and carry that fallback into the preview too.
49
+ - **Next.js:** map `next` in `package.json` dependencies, or a root `next.config.ts`/`.js`/`.mjs` file, to the `nextjs` tooling stack. The dependency name does not match the stack name by itself.
49
50
  - **Extras:** identify technologies not already covered by the picked stack. For each, find a rule whose `description` or `paths` points at that technology and pass it via `--add`. Do not add a rule the stack already pulls in.
50
51
  - **Skip (`--skip`):** `wiki` installs by default. Add `--skip wiki` only when the user explicitly wants it left out.
51
52
 
@@ -43,7 +43,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
43
43
  - [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
44
44
  - [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the family stem behind a templated citation, the exemption marker, the blind spots it cannot reach, and why it reports rather than gates
45
45
  - [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
46
- - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
46
+ - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, deriving the branch a dispatch and a worker both take, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
47
47
  - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
48
48
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
49
49
  - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Tasks
3
- description: Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
3
+ description: Selecting a shipped task by stem or pull request, recording a number and closing an outcome, deriving the branch a dispatch and a worker both take, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
4
4
  ---
5
5
 
6
6
  # Tasks
@@ -104,6 +104,32 @@ The orchestrator dispatch runbook calls this before it checks the branch or the
104
104
  canon tasks plan-answers dispatch-answer-gate --json | jq -r '.launchable'
105
105
  ```
106
106
 
107
+ ## Plan branch
108
+
109
+ `canon tasks plan-branch <plan>` derives the branch name from a plan file. It reports and never writes, and it names the plan the same two ways `canon tasks plan-answers` does, by path or by slug, against the same two bases.
110
+
111
+ ```bash
112
+ canon tasks plan-branch dispatch-answer-gate
113
+ canon tasks plan-branch .canon/plans/feature-dispatch-answer-gate.md --json
114
+ ```
115
+
116
+ | Option | Effect |
117
+ | --------------- | ------------------------------------------- |
118
+ | `--json` | Emit a machine-readable record on stdout |
119
+ | `--root <path>` | Board root, defaulting to the main worktree |
120
+
121
+ The record carries `type`, `slug`, `branch`, `words`, and `conforms`. Exit codes: `0` derived and conforming, `1` refused with `no-plan`, `archived`, or `bad-input`, `2` derived with `conforms` false. Branch on `conforms` rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
122
+
123
+ `slug` is the plan filename with its `feature-` prefix and its extension taken off, and `type` is the constant `feat`. Reading a type off the plan's prose was the alternative, and it is the half of the derivation that has already disagreed with itself: one dispatch checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`, both sides reading one plan. What makes the constant safe is that a branch type is cosmetic. `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff, so the semantics a release reads never pass through the branch name. What it costs is a worktree listing where every plan-derived branch reads `feat/`, and nothing renames it later.
124
+
125
+ `conforms` reads both caps `standards/branch.md` states, being 4 words on the description and 50 characters on the whole branch. A false reading is a row for a person rather than a name to shorten here, since a rename parts the branch slug from the plan slug that `claude-worktree` tier 1 and `git-pr`'s plan lookup both read back.
126
+
127
+ Both sides of a dispatch call it. The orchestrator's collision check derives its candidate here, and `claude-autoship` Step 0 derives the worktree it enters from the same plan, so the branch a gate clears and the branch a session takes are one string by construction. They were two readings of one paragraph until 2026-09-06, when four dispatches on one plan produced three different strings.
128
+
129
+ ```bash
130
+ canon tasks plan-branch dispatch-answer-gate --json | jq -r '.branch'
131
+ ```
132
+
107
133
  ## Plan link
108
134
 
109
135
  `canon tasks plan-link <task> <plan>` writes or corrects a task's `Plan:` line, as `Plan: [<label>](<target>)` right after the H1. `claude-feature` calls it right after a plan file lands, when Step 1 resolved an existing task for the feature, so the line is a mechanical write rather than hand-edited markdown.
@@ -83,7 +83,7 @@ Each diagram entry records the commit and date it was last verified against, and
83
83
 
84
84
  The default path is `base`. `canon init` on `base` installs base tooling configs, Claude seeds, and governance core rules, and scaffolds an empty `.claude/wiki/`. Most projects need nothing more.
85
85
 
86
- Escalate only for real web apps. The `setup-init` skill reads `package.json` and root configs, then picks the matching tooling stack (`vite-react` today) and the matching governance stack (`react`, `astro`, `node`).
86
+ Escalate only for real web apps. The `setup-init` skill reads `package.json` and root configs, then picks the matching tooling stack (`canon tooling list --json` names the current set) and the matching governance stack (`react`, `astro`, `node`).
87
87
 
88
88
  `node-server` is named rather than detected. It carries the server-side security and persistence rules for a project writing request handlers or a persistence layer in TypeScript, and the detect step matches a runtime or a framework against stack names, so nothing there marks a project as a backend. Pass it deliberately with `canon init --stack node-server` or `canon gov install node-server <target>`.
89
89
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.55.0",
4
+ "version": "4.56.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
4
+ import { type BranchOutcome, planBranch } from '@/tasks/branch'
4
5
  import {
5
6
  type ArchiveOutcome,
6
7
  archiveTask,
@@ -62,6 +63,11 @@ interface AnswersCommandOptions {
62
63
  readonly root?: string
63
64
  }
64
65
 
66
+ interface BranchCommandOptions {
67
+ readonly json?: boolean
68
+ readonly root?: string
69
+ }
70
+
65
71
  interface PullRequestCommandOptions {
66
72
  readonly json?: boolean
67
73
  readonly plan?: string
@@ -230,6 +236,42 @@ export function register(program: Command): void {
230
236
  process.exitCode = await runAnswers(plan, opts)
231
237
  })
232
238
 
239
+ tasks
240
+ .command('plan-branch')
241
+ .description('Derive the branch name a dispatch and a worker both take')
242
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
243
+ .helpOption('-h, --help', 'Show this help message')
244
+ .option('--json', 'Emit a machine-readable record on stdout')
245
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
246
+ .addHelpText(
247
+ 'after',
248
+ [
249
+ '',
250
+ 'Exit codes:',
251
+ ' 0 the branch is derived and conforms to standards/branch.md',
252
+ ' 1 refused as no-plan, archived, or bad-input',
253
+ ' 2 derived, and conforms is false because the slug breaks a cap',
254
+ '',
255
+ 'It reports type, slug, branch, words, and conforms. The type is fixed',
256
+ 'at feat, since a derivation two sides grade differently is the defect',
257
+ 'this closes, and git-branch renames a wrong type later in the chain.',
258
+ 'Branch on conforms rather than on the exit code, which a shell',
259
+ 'function wrapping canon can flatten to zero.',
260
+ '',
261
+ 'The dispatch collision check and the worker worktree entry both call',
262
+ 'it, so the branch a gate clears is the branch a session takes.',
263
+ '',
264
+ 'Examples:',
265
+ ' canon tasks plan-branch dispatch-answer-gate',
266
+ ' canon tasks plan-branch .canon/plans/feature-dispatch-answer-gate.md',
267
+ ' canon tasks plan-branch dispatch-answer-gate --json',
268
+ '',
269
+ ].join('\n'),
270
+ )
271
+ .action(async (plan: string, opts: BranchCommandOptions) => {
272
+ process.exitCode = await runBranch(plan, opts)
273
+ })
274
+
233
275
  tasks
234
276
  .command('pull-request')
235
277
  .description('Record a pull request number on the task a branch closes')
@@ -732,6 +774,61 @@ function reportAnswers(
732
774
  return EXIT_FINDINGS
733
775
  }
734
776
 
777
+ async function runBranch(
778
+ plan: string,
779
+ opts: BranchCommandOptions,
780
+ ): Promise<number> {
781
+ const root = opts.root ?? (await mainWorktreeRoot())
782
+ const outcome = planBranch(root, plan)
783
+
784
+ return reportBranch(outcome, opts.json ?? false, root)
785
+ }
786
+
787
+ function reportBranch(
788
+ outcome: BranchOutcome,
789
+ emitJson: boolean,
790
+ root: string,
791
+ ): number {
792
+ if (!outcome.ok) {
793
+ if (emitJson) {
794
+ process.stdout.write(
795
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
796
+ )
797
+ return 1
798
+ }
799
+
800
+ intro('canon tasks plan-branch')
801
+ logStep('Refused')
802
+ logError(outcome.message)
803
+ outro()
804
+ return 1
805
+ }
806
+
807
+ if (emitJson) {
808
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
809
+ return outcome.conforms ? 0 : EXIT_FINDINGS
810
+ }
811
+
812
+ intro('canon tasks plan-branch')
813
+ logStep(outcome.branch)
814
+
815
+ if (outcome.conforms) {
816
+ logInfo(`derived from ${outcome.plan}, ${outcome.words} words.`)
817
+ outro()
818
+ return 0
819
+ }
820
+
821
+ logWarn(
822
+ `derived from ${outcome.plan}, ${outcome.words} words and ${outcome.branch.length} characters.`,
823
+ )
824
+ logError(
825
+ 'The slug breaks a cap in standards/branch.md, so hand the row to a person rather than renaming it.',
826
+ )
827
+ outro()
828
+
829
+ return EXIT_FINDINGS
830
+ }
831
+
735
832
  function reportValidation(
736
833
  outcome: ValidateOutcome,
737
834
  emitJson: boolean,
@@ -15,6 +15,10 @@ import {
15
15
  SHIPPED_CORPORA,
16
16
  type ShippedReference,
17
17
  } from '@/shipped/references'
18
+ import {
19
+ README_PARAPHRASE_MARKER,
20
+ readmeCitationsIn,
21
+ } from '@/web/readme-citations'
18
22
 
19
23
  export interface CommandResult {
20
24
  readonly exitCode: number
@@ -622,6 +626,75 @@ export const clientCommandCitations = async (
622
626
  }
623
627
  }
624
628
 
629
+ /**
630
+ * Every `README.md:` anchor in `web/src/content/copy.ts` whose quoted phrase no
631
+ * longer appears in the current `README.md`, plus any leftover bare
632
+ * `README.md:<n>` citation the retired line-number convention would leave
633
+ * behind.
634
+ *
635
+ * Scoped to the one file carrying the anchors rather than walked across the
636
+ * tree, since `web/src/content/copy.ts` is the only place this repository
637
+ * writes one. A quoted phrase is checked with a plain substring test against
638
+ * the whole `README.md` text rather than against the cited line, which is the
639
+ * property that lets the anchor survive `README.md` growing or shrinking
640
+ * above it: the phrase fails only when the content itself moved or changed,
641
+ * never when a line number did.
642
+ */
643
+ export const readmeCitations: Measure = async (ctx) => {
644
+ const copyPath = 'web/src/content/copy.ts'
645
+ const readmePath = 'README.md'
646
+ const copyFile = join(ctx.root, copyPath)
647
+ const readmeFile = join(ctx.root, readmePath)
648
+
649
+ if (!existsSync(copyFile) || !existsSync(readmeFile)) {
650
+ return {
651
+ emissions: [],
652
+ unmeasured: `${copyPath} or ${readmePath} is absent, so no citation was checked.`,
653
+ }
654
+ }
655
+
656
+ const readmeText = readFileSync(readmeFile, 'utf8')
657
+ const citations = readmeCitationsIn(copyPath, readFileSync(copyFile, 'utf8'))
658
+
659
+ if (citations.length === 0) {
660
+ return {
661
+ emissions: [],
662
+ unmeasured: `${copyPath} carries no README.md citation, so nothing was checked.`,
663
+ }
664
+ }
665
+
666
+ const bad = citations.filter(
667
+ (citation) =>
668
+ citation.kind === 'bare' ||
669
+ (citation.kind === 'quoted' &&
670
+ citation.phrases.some((phrase) => !readmeText.includes(phrase))),
671
+ )
672
+
673
+ if (bad.length === 0) {
674
+ return {
675
+ emissions: [
676
+ info(
677
+ `${citations.length} README.md citation(s) in ${copyPath} verified against the current text`,
678
+ ),
679
+ ],
680
+ }
681
+ }
682
+
683
+ return {
684
+ emissions: bad.map((citation) =>
685
+ warn(
686
+ citation.kind === 'bare'
687
+ ? `${citation.file}:${citation.line} carries ${citation.text}, a bare line number that cannot detect a shifted line`
688
+ : `${citation.file}:${citation.line} carries ${citation.text}, whose quoted phrase no longer appears in README.md`,
689
+ ),
690
+ ),
691
+ failure:
692
+ bad.length === 1
693
+ ? `One README.md citation in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark the line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`
694
+ : `${bad.length} README.md citations in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark each line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`,
695
+ }
696
+ }
697
+
625
698
  /**
626
699
  * `canon sandbox coverage` moves only when a person runs it, so a scenario added
627
700
  * with no expectation ships unnoticed.
@@ -5,6 +5,7 @@ import {
5
5
  markdownBans,
6
6
  type Measure,
7
7
  pluginManifests,
8
+ readmeCitations,
8
9
  recordIdempotence,
9
10
  sandboxCoverage,
10
11
  seedStandards,
@@ -390,6 +391,16 @@ export const STAGES: readonly Stage[] = [
390
391
  skipped: 'No shipped corpus changed, so no reference was read',
391
392
  checks: [{ kind: 'measure', measure: shippedReferences }],
392
393
  },
394
+ {
395
+ // Scoped to the two files a citation can drift between, so an edit to
396
+ // either one runs the check. web/src/content/copy.ts carries the anchors
397
+ // and README.md is the text they quote from.
398
+ id: 'readme-citations',
399
+ label: 'README citations',
400
+ scope: /^(web\/src\/content\/copy\.ts|README\.md)$/,
401
+ skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
402
+ checks: [{ kind: 'measure', measure: readmeCitations }],
403
+ },
393
404
  {
394
405
  id: 'seed-standards',
395
406
  label: 'Seed standards',
@@ -47,6 +47,18 @@ export interface PlanAnswers {
47
47
 
48
48
  export type AnswersOutcome = PlanAnswers | AnswersRefused
49
49
 
50
+ /**
51
+ * A reference that named a live plan, carrying the absolute path a reader opens
52
+ * and the root-relative spelling a record reports.
53
+ */
54
+ export interface PlanResolved {
55
+ readonly ok: true
56
+ readonly path: string
57
+ readonly plan: string
58
+ }
59
+
60
+ export type ResolveOutcome = PlanResolved | AnswersRefused
61
+
50
62
  /**
51
63
  * The spellings a caller reaches a plan by, in the order they are tried. A bare
52
64
  * slug names the live folder outright, and a path is resolved against the
@@ -146,19 +158,18 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
146
158
  }
147
159
 
148
160
  /**
149
- * Answers whether a plan is launchable, which is whether it still waits on the
150
- * operator for a call only they can make. It reads the question block through
151
- * the same `readQuestions` the plan validator runs, so the gate and the
152
- * conformance check cannot drift into disagreeing about what a question is.
161
+ * Resolves a caller's reference to a live plan, refusing an empty reference, a
162
+ * reference that names no file, and one that lands in the plans archive.
153
163
  *
154
- * It reports and never writes. Holding the row, naming the slot, and reaching
155
- * the operator belong to the dispatcher, which is where the decision already
156
- * sits.
164
+ * Both `planAnswers` and `planBranch` answer the same reference, so the
165
+ * resolution and the three refusals sit here rather than in each of them. Two
166
+ * verbs reading one reference through two copies of this ladder is the defect
167
+ * the branch derivation was filed against, one layer down.
157
168
  */
158
- export async function planAnswers(
169
+ export function resolvePlanReference(
159
170
  root: string,
160
171
  reference: string,
161
- ): Promise<AnswersOutcome> {
172
+ ): ResolveOutcome {
162
173
  if (reference.trim().length === 0) {
163
174
  return refuse('bad-input', 'No plan named. Pass a plan path or its slug.')
164
175
  }
@@ -188,12 +199,32 @@ export async function planAnswers(
188
199
  )
189
200
  }
190
201
 
191
- const sections = splitPlanSections(await readFile(path, 'utf8'))
202
+ return { ok: true, path, plan: relative(root, path) }
203
+ }
204
+
205
+ /**
206
+ * Answers whether a plan is launchable, which is whether it still waits on the
207
+ * operator for a call only they can make. It reads the question block through
208
+ * the same `readQuestions` the plan validator runs, so the gate and the
209
+ * conformance check cannot drift into disagreeing about what a question is.
210
+ *
211
+ * It reports and never writes. Holding the row, naming the slot, and reaching
212
+ * the operator belong to the dispatcher, which is where the decision already
213
+ * sits.
214
+ */
215
+ export async function planAnswers(
216
+ root: string,
217
+ reference: string,
218
+ ): Promise<AnswersOutcome> {
219
+ const resolved = resolvePlanReference(root, reference)
220
+ if (!resolved.ok) return resolved
221
+
222
+ const sections = splitPlanSections(await readFile(resolved.path, 'utf8'))
192
223
  const open = openQuestions(sections.get('Questions') ?? [])
193
224
 
194
225
  return {
195
226
  ok: true,
196
- plan: relative(root, path),
227
+ plan: resolved.plan,
197
228
  launchable: open.length === 0,
198
229
  open,
199
230
  }
@@ -0,0 +1,78 @@
1
+ import { basename } from 'node:path'
2
+ import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
3
+
4
+ const PLAN_PREFIX = 'feature-'
5
+ const MARKDOWN = '.md'
6
+
7
+ /**
8
+ * The type every plan-derived branch takes. It is a constant rather than a
9
+ * reading, because determinism is the whole property that makes the dispatch
10
+ * gate and the worker agree, and prose reading is the judgment that produced
11
+ * three strings for one plan. A wrong type is cheap because a branch type is
12
+ * cosmetic, since a commit's type and a pull request's title are both read off
13
+ * the diff. Nothing renames it later, whatever three shipped bodies used to say.
14
+ */
15
+ export const PLAN_BRANCH_TYPE = 'feat'
16
+
17
+ /** The description cap in `standards/branch.md`, in kebab-separated words. */
18
+ export const DESCRIPTION_WORD_CAP = 4
19
+
20
+ /** The branch length cap in `standards/branch.md`, in characters. */
21
+ export const BRANCH_LENGTH_CAP = 50
22
+
23
+ export interface PlanBranch {
24
+ readonly ok: true
25
+ readonly plan: string
26
+ readonly type: string
27
+ readonly slug: string
28
+ readonly branch: string
29
+ readonly words: number
30
+ readonly conforms: boolean
31
+ }
32
+
33
+ export type BranchOutcome = PlanBranch | AnswersRefused
34
+
35
+ /**
36
+ * Takes the slug off a plan filename. The `feature-` prefix and the extension
37
+ * are the two things every plan filename carries and no branch name does, so
38
+ * both come off and whatever is left is the description.
39
+ */
40
+ function slugOf(path: string): string {
41
+ const stem = basename(path, MARKDOWN)
42
+
43
+ return stem.startsWith(PLAN_PREFIX) ? stem.slice(PLAN_PREFIX.length) : stem
44
+ }
45
+
46
+ /**
47
+ * Derives the branch a dispatch checks and a worker takes, from the plan both
48
+ * of them name. It is the one derivation, so the collision check and the
49
+ * worktree entry it gates cannot hold two answers for one plan.
50
+ *
51
+ * Conformance covers both caps `standards/branch.md` states, being the word
52
+ * count of the description and the length of the whole branch. A slug is a
53
+ * plan's own filename rather than a name anyone chose for a branch, so a plan
54
+ * can name a branch this refuses to grade as conforming, and reporting that is
55
+ * the point: the caller hands the row to a person rather than shipping a
56
+ * rename that parts the branch slug from the plan slug.
57
+ */
58
+ export function planBranch(root: string, reference: string): BranchOutcome {
59
+ const resolved = resolvePlanReference(root, reference)
60
+ if (!resolved.ok) return resolved
61
+
62
+ const slug = slugOf(resolved.path)
63
+ const branch = `${PLAN_BRANCH_TYPE}/${slug}`
64
+ const words = slug.split('-').filter((word) => word.length > 0).length
65
+
66
+ return {
67
+ ok: true,
68
+ plan: resolved.plan,
69
+ type: PLAN_BRANCH_TYPE,
70
+ slug,
71
+ branch,
72
+ words,
73
+ conforms:
74
+ words > 0 &&
75
+ words <= DESCRIPTION_WORD_CAP &&
76
+ branch.length <= BRANCH_LENGTH_CAP,
77
+ }
78
+ }
@@ -0,0 +1,90 @@
1
+ import { isMarked } from '@/exempt-marker'
2
+
3
+ export const README_PARAPHRASE_MARKER = 'canon-allow-readme-paraphrase'
4
+
5
+ export type ReadmeCitationKind = 'quoted' | 'paraphrase' | 'bare'
6
+
7
+ export interface ReadmeCitation {
8
+ readonly file: string
9
+ /** One-based, matching the `file:line` form a reader clicks. */
10
+ readonly line: number
11
+ readonly kind: ReadmeCitationKind
12
+ /** The citation comment as written, so a report names the line to fix. */
13
+ readonly text: string
14
+ /** Verbatim phrases to check against `README.md`, set only for `kind: 'quoted'`. */
15
+ readonly phrases: readonly string[]
16
+ }
17
+
18
+ const ANCHOR = /README\.md:\s*(.*)$/
19
+ const QUOTED_PHRASE = /"([^"]+)"/g
20
+
21
+ /**
22
+ * Every `README.md:` anchor comment in one file, classified by shape.
23
+ *
24
+ * `quoted` carries one or more verbatim phrases a caller checks against the
25
+ * current `README.md` text, which is what replaces a line number that drifts
26
+ * silently the moment the cited line moves. A quote is checked whether or not
27
+ * the line also carries `README_PARAPHRASE_MARKER`, since a marker documents
28
+ * that part of a string is synthesized and asserts nothing about a phrase the
29
+ * same line puts in quotes: quoting a borrow verbatim and then never checking
30
+ * it would let the exact drift this file exists to catch survive inside its
31
+ * own escape hatch. `paraphrase` is what a marked line falls to only once it
32
+ * carries no quote of its own, muted by `README_PARAPHRASE_MARKER` the way
33
+ * `isMarked` mutes every other exemption in this repository. `bare` is the
34
+ * retired `README.md:<n>` form, reported rather than accepted so the fragile
35
+ * convention this replaces cannot come back on a later edit.
36
+ *
37
+ * Modeled on `clientCommandCitationsIn` in `src/client-commands.ts`, including
38
+ * its use of `isMarked` for the exemption.
39
+ */
40
+ export function readmeCitationsIn(
41
+ file: string,
42
+ text: string,
43
+ ): ReadmeCitation[] {
44
+ const lines = text.split('\n')
45
+ const citations: ReadmeCitation[] = []
46
+
47
+ for (const [index, line] of lines.entries()) {
48
+ const match = ANCHOR.exec(line)
49
+ if (match === null) continue
50
+
51
+ const rest = (match[1] ?? '').trim()
52
+
53
+ const phrases = [...rest.matchAll(QUOTED_PHRASE)].map(
54
+ (found) => found[1] ?? '',
55
+ )
56
+ if (phrases.length > 0) {
57
+ citations.push({
58
+ file,
59
+ line: index + 1,
60
+ kind: 'quoted',
61
+ text: line.trim(),
62
+ phrases,
63
+ })
64
+ continue
65
+ }
66
+
67
+ if (isMarked(lines, index, README_PARAPHRASE_MARKER)) {
68
+ citations.push({
69
+ file,
70
+ line: index + 1,
71
+ kind: 'paraphrase',
72
+ text: line.trim(),
73
+ phrases: [],
74
+ })
75
+ continue
76
+ }
77
+
78
+ if (/^\d/.test(rest)) {
79
+ citations.push({
80
+ file,
81
+ line: index + 1,
82
+ kind: 'bare',
83
+ text: line.trim(),
84
+ phrases: [],
85
+ })
86
+ }
87
+ }
88
+
89
+ return citations
90
+ }
@@ -21,11 +21,13 @@ Does not govern:
21
21
  - Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
22
22
  - Length: 50 characters maximum
23
23
  - Casing: kebab-case only, no underscores or camelCase
24
- - Description: 2 words maximum, 3 only when genuinely needed for specificity
24
+ - Description: 2 words maximum, up to 4 only when genuinely needed for specificity
25
25
  - Capture the core change, not the commit message verbatim
26
26
  - For branches with multiple commits, use the unifying concern as the description.
27
27
  - Do not duplicate type in description (e.g., `feat/feature-login`)
28
28
 
29
+ The upper bound reads 4 rather than 3 for every branch, whoever named it. It was widened on 2026-09-06 so that a branch taking its description from a planning document's own filename stops being renamed at ship, since a rename there is a third derivation and parts the branch from the filename that later tooling reads back to find the document. Nothing can tell such a name from any other, so the wider bound holds for all of them and 2 words stays the target.
30
+
29
31
  ## Types
30
32
 
31
33
  - `feat`: new feature or capability
@@ -0,0 +1,90 @@
1
+ import js from '@eslint/js'
2
+ import { defineConfig, globalIgnores } from 'eslint/config'
3
+ import prettier from 'eslint-config-prettier'
4
+ import checkFile from 'eslint-plugin-check-file'
5
+ import reactHooks from 'eslint-plugin-react-hooks'
6
+ import reactRefresh from 'eslint-plugin-react-refresh'
7
+ import simpleImportSort from 'eslint-plugin-simple-import-sort'
8
+ import vitest from 'eslint-plugin-vitest'
9
+ import globals from 'globals'
10
+ import tseslint from 'typescript-eslint'
11
+
12
+ export default defineConfig([
13
+ globalIgnores([
14
+ '.next',
15
+ 'next-env.d.ts',
16
+ 'dist',
17
+ 'dist-ssr',
18
+ 'coverage',
19
+ 'release',
20
+ '.claude',
21
+ '.vscode',
22
+ '.husky',
23
+ 'test-results',
24
+ 'playwright-report',
25
+ 'blob-report',
26
+ 'playwright/.cache',
27
+ ]),
28
+ js.configs.recommended,
29
+ ...tseslint.configs.recommended,
30
+ {
31
+ files: ['**/*.{ts,tsx,js,jsx}'],
32
+ plugins: {
33
+ 'react-hooks': reactHooks,
34
+ 'react-refresh': reactRefresh,
35
+ 'simple-import-sort': simpleImportSort,
36
+ 'check-file': checkFile,
37
+ },
38
+ languageOptions: {
39
+ globals: {
40
+ ...globals.browser,
41
+ },
42
+ },
43
+ rules: {
44
+ ...reactHooks.configs.recommended.rules,
45
+ 'react-refresh/only-export-components': [
46
+ 'warn',
47
+ { allowConstantExport: true },
48
+ ],
49
+ 'simple-import-sort/imports': 'error',
50
+ 'simple-import-sort/exports': 'error',
51
+ '@typescript-eslint/no-unused-vars': [
52
+ 'error',
53
+ { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
54
+ ],
55
+ 'check-file/filename-naming-convention': [
56
+ 'error',
57
+ { '**/*.{ts,tsx}': 'KEBAB_CASE' },
58
+ { ignoreMiddleExtensions: true },
59
+ ],
60
+ 'check-file/folder-naming-convention': [
61
+ 'error',
62
+ { 'src/**/!(__tests__)': 'KEBAB_CASE' },
63
+ ],
64
+ },
65
+ },
66
+ {
67
+ // App Router route and layout files export non-component values (metadata, route handlers), which this rule flags as violations.
68
+ files: ['src/app/**/*.{ts,tsx}'],
69
+ rules: {
70
+ 'react-refresh/only-export-components': 'off',
71
+ },
72
+ },
73
+ {
74
+ files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
75
+ ...vitest.configs.recommended,
76
+ },
77
+ {
78
+ files: [
79
+ '*.config.{js,mjs,cjs,ts}',
80
+ 'vitest.config.ts',
81
+ 'playwright.config.ts',
82
+ ],
83
+ languageOptions: {
84
+ globals: {
85
+ ...globals.node,
86
+ },
87
+ },
88
+ },
89
+ prettier,
90
+ ])
@@ -0,0 +1,10 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const nextConfig: NextConfig = {
4
+ agentRules: false,
5
+ turbopack: {
6
+ root: import.meta.dirname,
7
+ },
8
+ }
9
+
10
+ export default nextConfig
@@ -0,0 +1,27 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ const isCI = !!process.env.CI
4
+ const baseURL = `http://localhost:${3000 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
5
+
6
+ export default defineConfig({
7
+ testDir: 'e2e',
8
+ forbidOnly: isCI,
9
+ retries: isCI ? 2 : 0,
10
+ reporter: isCI ? 'list' : 'html',
11
+ use: {
12
+ trace: 'on-first-retry',
13
+ baseURL,
14
+ },
15
+ projects: [
16
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
17
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
18
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
19
+ ],
20
+ webServer: {
21
+ command: process.env.DIST_PREBUILT
22
+ ? 'bun run preview'
23
+ : 'bun run build && bun run preview',
24
+ url: baseURL,
25
+ reuseExistingServer: false,
26
+ },
27
+ })
@@ -0,0 +1,28 @@
1
+ import path from 'node:path'
2
+
3
+ import { defineConfig } from 'vitest/config'
4
+
5
+ export default defineConfig({
6
+ resolve: {
7
+ alias: {
8
+ '@': path.resolve(__dirname, 'src'),
9
+ },
10
+ },
11
+ test: {
12
+ environment: 'jsdom',
13
+ globals: true,
14
+ setupFiles: ['src/test/setup.ts'],
15
+ passWithNoTests: true,
16
+ exclude: [
17
+ '**/node_modules/**',
18
+ '**/.next/**',
19
+ '**/e2e/**',
20
+ '**/.{idea,git,cache,output,temp}/**',
21
+ ],
22
+ coverage: {
23
+ provider: 'v8',
24
+ reporter: ['text', 'json', 'html'],
25
+ exclude: ['node_modules/', 'src/test/setup.ts', 'e2e/'],
26
+ },
27
+ },
28
+ })
@@ -0,0 +1,19 @@
1
+ [stack]
2
+ name = "nextjs"
3
+ extends = "web"
4
+ runtime = "bun"
5
+ scaffold = "bunx create-next-app@latest {{name}} --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --use-bun --skip-install --disable-git --no-agents-md --yes"
6
+
7
+ [dependencies.dev]
8
+ packages = [
9
+ "next",
10
+ "@tailwindcss/postcss",
11
+ ]
12
+
13
+ [scripts]
14
+ "preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next start --port $((3000 + WORKTREE_PORT_OFFSET))"
15
+ "typecheck" = "next typegen && tsc --noEmit"
16
+
17
+ [scripts.override]
18
+ "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
19
+ "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
@@ -0,0 +1,47 @@
1
+ # Tooling nextjs reference
2
+
3
+ > Extends: `web`. Apply web stack first.
4
+
5
+ ## Overview
6
+
7
+ The nextjs stack covers Next.js + TypeScript projects using the App Router. It ships golden configs for `next.config.ts`, `vitest.config.ts` (plain `defineConfig`, no bundler merge), `playwright.config.ts` (build-then-preview), and an `eslint.config.js` that extends the web layer's config with `.next` ignores and an App Router override. Shared web tooling (ESLint base, screenshot template, VS Code, CI, verify script) comes from the `web` layer.
8
+
9
+ ## Scaffold checklist
10
+
11
+ 1. Scaffold with `bunx create-next-app@latest <name> --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-bun --skip-install --disable-git --no-agents-md --yes`. `--no-agents-md` skips the scaffold-time `AGENTS.md`/`CLAUDE.md` write, which otherwise duplicates the root `CLAUDE.md`.
12
+ 2. Install web tooling: `canon tooling sync web . --write`
13
+ 3. Install nextjs configs: `canon tooling sync nextjs . --write`
14
+ 4. Extend the `ci` and `development` context entries under `.claude/context/` per the web reference's extend sections plus the nextjs rows below.
15
+ 5. Run `bun run lint:fix` then `bun run check`.
16
+
17
+ ## What ships as golden configs
18
+
19
+ - `next.config.ts`: `agentRules: false` stops Next from regenerating `AGENTS.md`/`CLAUDE.md` on every run, which would otherwise compete with the root `CLAUDE.md`. `turbopack.root: import.meta.dirname` pins the workspace root, silencing Next's multi-lockfile inference warning in any checkout carrying more than one lockfile above the project, a worktree included.
20
+ - `vitest.config.ts`: plain `defineConfig`, no `mergeConfig` or `getViteConfig` since Next has no Vite config to merge from. jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `.next/**` in test excludes.
21
+ - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `3000` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. `DIST_PREBUILT` drops the `build` half, matching the astro stack's flag.
22
+ - `eslint.config.js`: overrides the web layer with `.next` and `next-env.d.ts` added to `globalIgnores`, keeping `react-hooks`/`react-refresh` since this stack still ships React components. `react-refresh/only-export-components` is `off` for `src/app/**`, since route and layout files export non-component values the rule would otherwise flag.
23
+
24
+ ## Port
25
+
26
+ Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or `vite.config.ts`'s `server.port`. The `${base} + WORKTREE_PORT_OFFSET` formula is computed twice instead of once: in `playwright.config.ts` as a JS expression, and in `[scripts.override]` for `dev` and `preview` as shell arithmetic passed to `next`'s own `--port` flag. Default port is `3000`, Next's own default.
27
+
28
+ ## Typecheck
29
+
30
+ `typecheck` runs `next typegen && tsc --noEmit`. The App Router's route-level types (`LayoutProps`, `PageProps`) are generated into `.next/types/`, gitignored and absent from a fresh checkout, and `tsc` fails on them unresolved without the typegen step first.
31
+
32
+ ## No golden `tsconfig.json`
33
+
34
+ `create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
35
+
36
+ ## Development docs (extend)
37
+
38
+ Append to the `## Scripts` table:
39
+
40
+ | `bun run dev` | Start the Next dev server on port 3000, plus this worktree's port offset. |
41
+ | `bun run build` | Build the production bundle. |
42
+ | `bun run preview` | Serve the built bundle locally, on port 3000 plus the worktree offset. |
43
+ | `bun run typecheck` | Run `next typegen` then `tsc --noEmit`. |
44
+
45
+ ## CI docs (extend)
46
+
47
+ In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``.
@@ -0,0 +1,3 @@
1
+ logomark
2
+ turbopack
3
+ vercel