@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +8 -1
- package/claude/skills/claude-autoship/SKILL.md +17 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +17 -7
- package/claude/skills/claude-worktree/SKILL.md +9 -3
- package/claude/skills/context-draft/REQUIREMENT.md +37 -0
- package/claude/skills/context-draft/SKILL.md +59 -0
- package/claude/skills/create-skill/REQUIREMENT.md +1 -1
- package/claude/skills/create-standard/SKILL.md +1 -1
- package/claude/skills/index-lookup/REQUIREMENT.md +35 -0
- package/claude/skills/index-lookup/SKILL.md +42 -0
- package/claude/skills/setup-init/SKILL.md +2 -1
- package/claude/skills/wireframe-draft/REQUIREMENT.md +38 -0
- package/claude/skills/wireframe-draft/SKILL.md +64 -0
- package/claude/skills/youtube-transcripts/SKILL.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +27 -1
- package/docs/target-projects.md +1 -1
- package/docs/workflow/ai-workflow.md +15 -12
- package/package.json +1 -1
- package/src/claude/cases/authoring.ts +10 -0
- package/src/claude/cases/misc.ts +4 -0
- package/src/commands/tasks.ts +97 -0
- package/src/gate/measures.ts +73 -0
- package/src/gate/stages.ts +11 -0
- package/src/tasks/answers.ts +42 -11
- package/src/tasks/branch.ts +78 -0
- package/src/web/readme-citations.ts +90 -0
- package/standards/branch.md +3 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
- package/tooling/nextjs/configs/eslint.config.js +90 -0
- package/tooling/nextjs/configs/next.config.ts +10 -0
- package/tooling/nextjs/configs/playwright.config.ts +27 -0
- package/tooling/nextjs/configs/vitest.config.ts +28 -0
- package/tooling/nextjs/manifest.toml +19 -0
- package/tooling/nextjs/reference.md +47 -0
- 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',
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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
|
]
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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,
|
package/src/gate/measures.ts
CHANGED
|
@@ -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.
|
package/src/gate/stages.ts
CHANGED
|
@@ -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',
|
package/src/tasks/answers.ts
CHANGED
|
@@ -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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
*
|
|
155
|
-
* the
|
|
156
|
-
*
|
|
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
|
|
169
|
+
export function resolvePlanReference(
|
|
159
170
|
root: string,
|
|
160
171
|
reference: string,
|
|
161
|
-
):
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/standards/branch.md
CHANGED
|
@@ -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,
|
|
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,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
|
+
})
|