@erclx/canon 4.54.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.
Files changed (37) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-cli/SKILL.md +8 -1
  3. package/claude/skills/claude-autoship/SKILL.md +17 -1
  4. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +17 -7
  5. package/claude/skills/claude-worktree/SKILL.md +9 -3
  6. package/claude/skills/context-draft/REQUIREMENT.md +37 -0
  7. package/claude/skills/context-draft/SKILL.md +59 -0
  8. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  9. package/claude/skills/create-standard/SKILL.md +1 -1
  10. package/claude/skills/index-lookup/REQUIREMENT.md +35 -0
  11. package/claude/skills/index-lookup/SKILL.md +42 -0
  12. package/claude/skills/setup-init/SKILL.md +2 -1
  13. package/claude/skills/wireframe-draft/REQUIREMENT.md +38 -0
  14. package/claude/skills/wireframe-draft/SKILL.md +64 -0
  15. package/claude/skills/youtube-transcripts/SKILL.md +1 -1
  16. package/docs/agents/index.md +1 -1
  17. package/docs/agents/tasks.md +27 -1
  18. package/docs/target-projects.md +1 -1
  19. package/docs/workflow/ai-workflow.md +15 -12
  20. package/package.json +1 -1
  21. package/src/claude/cases/authoring.ts +10 -0
  22. package/src/claude/cases/misc.ts +4 -0
  23. package/src/commands/tasks.ts +97 -0
  24. package/src/gate/measures.ts +73 -0
  25. package/src/gate/stages.ts +11 -0
  26. package/src/tasks/answers.ts +42 -11
  27. package/src/tasks/branch.ts +78 -0
  28. package/src/web/readme-citations.ts +90 -0
  29. package/standards/branch.md +3 -1
  30. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
  31. package/tooling/nextjs/configs/eslint.config.js +90 -0
  32. package/tooling/nextjs/configs/next.config.ts +10 -0
  33. package/tooling/nextjs/configs/playwright.config.ts +27 -0
  34. package/tooling/nextjs/configs/vitest.config.ts +28 -0
  35. package/tooling/nextjs/manifest.toml +19 -0
  36. package/tooling/nextjs/reference.md +47 -0
  37. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
@@ -42,6 +42,16 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
42
42
  'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
43
43
  expect: 'docs-draft',
44
44
  },
45
+ {
46
+ prompt:
47
+ 'Write a context entry for the payments domain, there is no .claude/context page for it yet.',
48
+ expect: 'context-draft',
49
+ },
50
+ {
51
+ prompt:
52
+ 'Draft a wireframe for the settings panel, nothing under .claude/wireframes covers that surface yet.',
53
+ expect: 'wireframe-draft',
54
+ },
45
55
  {
46
56
  prompt: 'Say what that dense answer actually means in plain terms.',
47
57
  expect: 'restate-plainly',
@@ -33,4 +33,8 @@ export const MISC_CASES: readonly SkillCase[] = [
33
33
  prompt: 'Does our github about text still match what the readme says?',
34
34
  expect: 'repo-metadata',
35
35
  },
36
+ {
37
+ prompt: 'Is there a page anywhere in this repo that covers retries?',
38
+ expect: 'index-lookup',
39
+ },
36
40
  ]
@@ -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
@@ -53,5 +53,5 @@ marker="$marker_dir/$key"
53
53
  mkdir -p "$marker_dir"
54
54
  : >"$marker"
55
55
 
56
- msg=$(printf 'An index.md exists at %s. Read it before searching this folder, it catalogs the sibling files faster than a blind search.' "$index")
56
+ msg=$(printf 'An index.md exists at %s. Read it before searching this folder, it catalogs the sibling files faster than a blind search. For a cross-folder answer, run canon indexes list --json or invoke the index-lookup skill.' "$index")
57
57
  jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
@@ -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
+ })