@erclx/canon 4.70.0 → 4.72.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 (39) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +4 -0
  4. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
  5. package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
  6. package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
  7. package/claude/skills/draft-and-pick/SKILL.md +10 -6
  8. package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
  9. package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
  10. package/claude/skills/git-ship/SKILL.md +17 -6
  11. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
  12. package/claude/skills/role-planner/SKILL.md +5 -0
  13. package/claude/skills/role-worker/SKILL.md +6 -0
  14. package/claude/skills/session-relay/REQUIREMENT.md +41 -0
  15. package/claude/skills/session-relay/SKILL.md +36 -0
  16. package/claude/skills/ux-audit/SKILL.md +3 -0
  17. package/docs/agents/key-changes.md +5 -1
  18. package/docs/agents/tasks.md +33 -0
  19. package/docs/workflow/ai-workflow.md +2 -1
  20. package/docs/workflow/visual-design-workflow.md +1 -1
  21. package/governance/rules/ui/440-surface-capture.md +1 -0
  22. package/package.json +1 -1
  23. package/src/claude/cases/misc.ts +5 -1
  24. package/src/claude/cases/workflow.ts +5 -0
  25. package/src/commands/pr.ts +128 -6
  26. package/src/commands/tasks.ts +154 -0
  27. package/src/commands/teach.ts +2 -0
  28. package/src/gate/measures.ts +66 -0
  29. package/src/gate/stages.ts +11 -0
  30. package/src/git-files.ts +69 -0
  31. package/src/migrate/skill-names.ts +10 -0
  32. package/src/pr/bijection.ts +59 -1
  33. package/src/tasks/reach.ts +383 -0
  34. package/src/teach/workspace.ts +60 -23
  35. package/standards/plan.md +2 -0
  36. package/standards/tasks.md +2 -0
  37. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
  38. package/tooling/cloudflare/manifest.toml +5 -0
  39. package/tooling/cloudflare/reference.md +24 -0
@@ -0,0 +1,383 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { basename, join } from 'node:path'
4
+ import { listChangedFiles, resolveBaseRef } from '@/git-files'
5
+ import { recordDir } from '@/record-root'
6
+ import { splitPlanSections } from '@/records/validate'
7
+ import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
8
+ import { orderingPath, readBoard } from '@/tasks/validate'
9
+
10
+ const PLANS = 'plans'
11
+ const MARKDOWN = '.md'
12
+ const NONE_IDENTIFIED = 'None identified.'
13
+
14
+ /** The one group a dispatch reads, per `standards/tasks.md`. */
15
+ const DISPATCH_GROUP = 'Run now'
16
+
17
+ export const REACH_REFUSALS = [
18
+ 'no-plan',
19
+ 'archived',
20
+ 'bad-input',
21
+ 'no-base',
22
+ 'no-diff',
23
+ ] as const
24
+
25
+ export type ReachRefusal = (typeof REACH_REFUSALS)[number]
26
+
27
+ export interface ReachRefused {
28
+ readonly ok: false
29
+ readonly reason: ReachRefusal
30
+ readonly message: string
31
+ readonly detail: readonly string[]
32
+ }
33
+
34
+ /**
35
+ * One surface holding a path: a live plan, named by its filename stem, or a
36
+ * `## Run now` row, named by its task label.
37
+ */
38
+ export interface Holder {
39
+ readonly name: string
40
+ readonly source: 'plan' | 'row'
41
+ readonly declaration: string
42
+ /**
43
+ * Whether a holding plan also carries a row in `## Run now`, and undefined on
44
+ * a row holder, which is one by construction.
45
+ *
46
+ * A plan with no row is the shape a plan nobody archived takes, and it is
47
+ * also the shape of one whose task is merely not dispatched yet, so this
48
+ * narrows a reader's search rather than answering it. Testing whether the
49
+ * holder is genuinely in flight would put a second liveness reading here
50
+ * beside the dispatch gate's own, which the report declines.
51
+ */
52
+ readonly rowed?: boolean
53
+ }
54
+
55
+ /**
56
+ * One changed path other tracks already hold, carrying every holder and the
57
+ * declaration each matched on. A count alone leaves the reader diffing two file
58
+ * lists by eye to find which pair collided.
59
+ *
60
+ * Grouped by path rather than by holder. The two sources are read separately
61
+ * and stay separate inside `holders`, since a board cell and a plan's own list
62
+ * answer different questions, but one track carrying both reported the same
63
+ * path twice under two names before this, and the report leads on this list.
64
+ */
65
+ export interface Claim {
66
+ readonly path: string
67
+ readonly holders: readonly Holder[]
68
+ }
69
+
70
+ export interface ReachReport {
71
+ readonly ok: true
72
+ readonly plan: string
73
+ readonly base: string
74
+ readonly changed: number
75
+ readonly declared: readonly string[]
76
+ /** Leads the report. Short on nearly every branch, and the half worth acting on. */
77
+ readonly claimed: readonly Claim[]
78
+ readonly undeclared: readonly string[]
79
+ /** Live plans compared against, this branch's own excluded. */
80
+ readonly plans: number
81
+ /** `## Run now` rows compared against, the row citing this plan excluded. */
82
+ readonly rows: number
83
+ /** Whether an ordering file was on disk to read rows from at all. */
84
+ readonly board: boolean
85
+ }
86
+
87
+ export type ReachOutcome = ReachReport | ReachRefused
88
+
89
+ export interface ReachOptions {
90
+ /**
91
+ * Where the git range is read, defaulting to the records root.
92
+ *
93
+ * The two part company on every ordinary run, since the plans and the board
94
+ * are shared scratch at the main worktree root while the branch's own commits
95
+ * are in a linked worktree. Reading the range at the records root there
96
+ * measures a checkout sitting on the trunk, so the range closes on itself and
97
+ * every branch reports a reach of nothing. `canon gov test-order` carries the
98
+ * same split as an instruction in a skill body; this one is a parameter.
99
+ */
100
+ readonly repo?: string
101
+ /** The far side of the range, defaulting to the trunk. */
102
+ readonly ref?: string
103
+ }
104
+
105
+ /**
106
+ * Splits an entry at the colon that opens its reason, which is the first one
107
+ * standing outside a backticked span.
108
+ *
109
+ * The span test is what keeps `canon:docs-sync` and a `<slug>: <title>` label
110
+ * from ending the subject early, and it is the ordinary case rather than a
111
+ * corner: a reason routinely names a skill, and a skill is spelled with a
112
+ * colon inside backticks.
113
+ */
114
+ function subjectOf(entry: string): string {
115
+ let fenced = false
116
+
117
+ for (let i = 0; i < entry.length; i += 1) {
118
+ const char = entry[i]
119
+ if (char === '`') fenced = !fenced
120
+ else if (char === ':' && !fenced) return entry.slice(0, i)
121
+ }
122
+
123
+ return entry
124
+ }
125
+
126
+ /**
127
+ * Whether a span names a file rather than a skill, a command, or a version.
128
+ * It is `readPaths`' test, held here rather than shared, because that function
129
+ * reads a whole cell where this reads one already-split subject.
130
+ */
131
+ function namesFile(span: string): boolean {
132
+ return span.includes('/') || /\.[A-Za-z][A-Za-z0-9]*$/.test(span)
133
+ }
134
+
135
+ /**
136
+ * Reads the paths a plan declares out of its `**Files to touch:**` section.
137
+ *
138
+ * A declaration is a backticked span standing as an entry's subject, ahead of
139
+ * the colon opening its reason. Reading every span in the entry was the
140
+ * alternative and it reports a pair that was never going to collide, since the
141
+ * standard invites an entry to explain itself and an explanation names other
142
+ * files: one archived plan cites `src/gate/measures.ts` inside a reason whose
143
+ * subject is a sandbox arm, and never wrote it. Measured over the 2026-09-08
144
+ * wave, the subject rule reports 6 crossing pairs against 14 for every span.
145
+ *
146
+ * An entry carrying no colon at all is taken whole. The standard requires a
147
+ * reason rather than the punctuation introducing it, so the alternative is
148
+ * reading such an entry as declaring nothing, which reports every path it
149
+ * names as undeclared. `standards/plan.md` fixes the colon form, so the
150
+ * conforming entry never reaches this fallback.
151
+ *
152
+ * A rename declares both sides, since both are paths the branch writes and
153
+ * both sit ahead of the colon.
154
+ */
155
+ export function readDeclarations(text: string): string[] {
156
+ const section = splitPlanSections(text).get('Files to touch') ?? []
157
+ const declared: string[] = []
158
+
159
+ for (const line of section) {
160
+ const trimmed = line.trim()
161
+ if (!trimmed.startsWith('- ') || trimmed === `- ${NONE_IDENTIFIED}`)
162
+ continue
163
+
164
+ const spans = subjectOf(trimmed.slice(2)).match(/`[^`]+`/g) ?? []
165
+
166
+ for (const span of spans) {
167
+ const path = span
168
+ .slice(1, -1)
169
+ .trim()
170
+ .replace(/^\.\//, '')
171
+ .replace(/\/+$/, '')
172
+
173
+ if (namesFile(path)) declared.push(path)
174
+ }
175
+ }
176
+
177
+ return [...new Set(declared)]
178
+ }
179
+
180
+ /**
181
+ * Whether a declaration covers a changed path: the same file, or a folder the
182
+ * file sits under.
183
+ *
184
+ * Containment runs one way only. A changed path is always a file, so a
185
+ * declaration of `src/tasks/reach.ts` covering a change to `src/tasks/other.ts`
186
+ * would be the folder claim its author did not write, and the board's own
187
+ * `sharesPath` reads both directions because two rows can each name a folder.
188
+ */
189
+ function covers(declaration: string, path: string): boolean {
190
+ return path === declaration || path.startsWith(`${declaration}/`)
191
+ }
192
+
193
+ function stemOf(path: string): string {
194
+ return basename(path, MARKDOWN)
195
+ }
196
+
197
+ /**
198
+ * Every live plan beside this one, as a stem and the paths it declares. The
199
+ * archive is a folder below this one and is never walked, so a shipped plan
200
+ * cannot claim a path against a branch building today.
201
+ */
202
+ async function otherPlans(
203
+ root: string,
204
+ own: string,
205
+ ): Promise<{ stem: string; declared: readonly string[] }[]> {
206
+ const dir = recordDir(root, PLANS)
207
+ if (!existsSync(dir)) return []
208
+
209
+ const names = (await readdir(dir)).filter(
210
+ (name) => name.endsWith(MARKDOWN) && name !== basename(own),
211
+ )
212
+ names.sort()
213
+
214
+ const plans = []
215
+ for (const name of names) {
216
+ const text = await readFile(join(dir, name), 'utf8')
217
+ plans.push({ stem: stemOf(name), declared: readDeclarations(text) })
218
+ }
219
+
220
+ return plans
221
+ }
222
+
223
+ /**
224
+ * Every `## Run now` cell beside this branch's own row. A cell is copied from a
225
+ * plan's own list, so the board catches a row whose plan is absent and the
226
+ * plans folder catches a claim no cell carried.
227
+ */
228
+ async function dispatchRows(
229
+ root: string,
230
+ own: string,
231
+ ): Promise<{
232
+ read: boolean
233
+ rows: { label: string; plan: string | undefined; touches: string[] }[]
234
+ }> {
235
+ const ordering = orderingPath(root)
236
+ if (!existsSync(ordering)) return { read: false, rows: [] }
237
+
238
+ const { rows } = readBoard(await readFile(ordering, 'utf8'))
239
+ const ownStem = stemOf(own)
240
+
241
+ return {
242
+ read: true,
243
+ rows: rows
244
+ .filter((row) => row.group === DISPATCH_GROUP)
245
+ .filter((row) => row.plan === undefined || stemOf(row.plan) !== ownStem)
246
+ .map((row) => ({
247
+ label: row.label,
248
+ plan: row.plan === undefined ? undefined : stemOf(row.plan),
249
+ touches: [...(row.touches ?? [])],
250
+ })),
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Every holder of one path, plans first. Returning a list rather than a claim
256
+ * per source is what keeps a track carrying both a plan and a row from reading
257
+ * as two tracks in a report that leads on this list.
258
+ */
259
+ function holdersOf(
260
+ path: string,
261
+ plans: readonly { stem: string; declared: readonly string[] }[],
262
+ rows: readonly {
263
+ label: string
264
+ plan: string | undefined
265
+ touches: readonly string[]
266
+ }[],
267
+ ): Holder[] {
268
+ const holders: Holder[] = []
269
+
270
+ for (const plan of plans) {
271
+ const declaration = plan.declared.find((entry) => covers(entry, path))
272
+ if (declaration !== undefined) {
273
+ holders.push({
274
+ name: plan.stem,
275
+ source: 'plan',
276
+ declaration,
277
+ rowed: rows.some((row) => row.plan === plan.stem),
278
+ })
279
+ }
280
+ }
281
+
282
+ for (const row of rows) {
283
+ const declaration = row.touches.find((entry) => covers(entry, path))
284
+ if (declaration !== undefined) {
285
+ holders.push({ name: row.label, source: 'row', declaration })
286
+ }
287
+ }
288
+
289
+ return holders
290
+ }
291
+
292
+ /**
293
+ * Reads a branch back against what was written down about it: the paths its own
294
+ * plan declared, and the paths every other live plan or dispatch row holds.
295
+ *
296
+ * It reports and never gates, the way `canon gov test-order` does, and it
297
+ * writes nothing. A `Touches` cell is the controller's to correct and a plan's
298
+ * list is a prediction the branch outgrows, so a verb that repaired either
299
+ * would be answering a question the reader has not been shown yet.
300
+ *
301
+ * What it reads is what is written down, so it inherits the dispatch runbook's
302
+ * own blindness. A hand-launched track carries no row and a track with no plan
303
+ * carries no declaration, which is why the report names how many plans and rows
304
+ * it compared against rather than reporting clear and letting that read as a
305
+ * proof.
306
+ *
307
+ * The live folder is trusted rather than tested, which cuts the other way: a
308
+ * plan whose work merged keeps claiming its files until something archives it,
309
+ * and `canon tasks archive` only runs on merge, so a stranded plan reports a
310
+ * collision against every branch after it. The first run of this verb reported
311
+ * five such paths and every one was a file nobody archived. Testing a holder for
312
+ * liveness would put a second reading of what is in flight here, beside the
313
+ * dispatch gate's own, so the report names the holder and the reader decides.
314
+ */
315
+ export async function planReach(
316
+ root: string,
317
+ reference: string,
318
+ { repo = root, ref }: ReachOptions = {},
319
+ ): Promise<ReachOutcome> {
320
+ const resolved = resolvePlanReference(root, reference)
321
+ if (!resolved.ok) return widen(resolved)
322
+
323
+ const base = await resolveBaseRef(repo, ref)
324
+ if (base === undefined) {
325
+ return refuse(
326
+ 'no-base',
327
+ `No merge base against ${ref ?? 'origin/main or main'}, so the branch has no range to read.`,
328
+ [reference],
329
+ )
330
+ }
331
+
332
+ const changed = await listChangedFiles(repo, base)
333
+ if (changed === undefined) {
334
+ return refuse(
335
+ 'no-diff',
336
+ `git could not list the files changed since ${base}, so the reach is unread rather than clear.`,
337
+ [reference],
338
+ )
339
+ }
340
+
341
+ const declared = readDeclarations(await readFile(resolved.path, 'utf8'))
342
+ const plans = await otherPlans(root, resolved.path)
343
+ const board = await dispatchRows(root, resolved.path)
344
+
345
+ const claimed: Claim[] = []
346
+ const undeclared: string[] = []
347
+
348
+ for (const path of changed) {
349
+ const holders = holdersOf(path, plans, board.rows)
350
+ if (holders.length > 0) claimed.push({ path, holders })
351
+ if (!declared.some((entry) => covers(entry, path))) undeclared.push(path)
352
+ }
353
+
354
+ return {
355
+ ok: true,
356
+ plan: resolved.plan,
357
+ base,
358
+ changed: changed.length,
359
+ declared,
360
+ claimed,
361
+ undeclared,
362
+ plans: plans.length,
363
+ rows: board.rows.length,
364
+ board: board.read,
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Carries a resolution refusal through unchanged. The reasons are a subset of
370
+ * this verb's own, so restating the message here would put one wording in two
371
+ * places that ship together.
372
+ */
373
+ function widen(refused: AnswersRefused): ReachRefused {
374
+ return refused
375
+ }
376
+
377
+ function refuse(
378
+ reason: ReachRefusal,
379
+ message: string,
380
+ detail: readonly string[] = [],
381
+ ): ReachRefused {
382
+ return { ok: false, reason, message, detail }
383
+ }
@@ -42,9 +42,20 @@ export const TEACH_ASSETS = 'assets'
42
42
  * The one stylesheet every lesson in a workspace links. The name is fixed here
43
43
  * rather than chosen per lesson, because the second lesson has to reach the
44
44
  * file the first one wrote and a name composed twice is a name that can differ.
45
+ * This file is workspace-owned: `writeStylesheet` writes it once and leaves it
46
+ * alone, since a lesson adds its own rules here. It imports `TEACH_STYLESHEET_BASE`
47
+ * for the tokens and components, which is the half a resync may always replace.
45
48
  */
46
49
  export const TEACH_STYLESHEET = 'course.css'
47
50
 
51
+ /**
52
+ * The seeded half of a workspace's styling, imported by `TEACH_STYLESHEET`.
53
+ * `writeStylesheet` rewrites this file from the design source on every call,
54
+ * since nothing workspace-authored lives in it, which is what lets a design
55
+ * token move without a `--force` flag discarding a workspace's lesson rules.
56
+ */
57
+ export const TEACH_STYLESHEET_BASE = 'base.css'
58
+
48
59
  /**
49
60
  * The mission heading whose list a session reads as exit criteria. The writer,
50
61
  * the reader below, and the record validator all match this one spelling, so a
@@ -1001,31 +1012,40 @@ export interface StylesheetWritten {
1001
1012
  readonly slug: string
1002
1013
  /** Relative to the root, so a caller prints a path a reader can open. */
1003
1014
  readonly path: string
1004
- /** False when the workspace already held one and this call left it alone. */
1015
+ /** Relative to the root, the re-synced seed `path` imports. */
1016
+ readonly basePath: string
1017
+ /** False when the workspace already held `path` and this call left it alone. */
1005
1018
  readonly written: boolean
1006
1019
  }
1007
1020
 
1008
1021
  export type StylesheetOutcome = StylesheetWritten | TeachRefused
1009
1022
 
1010
- const STYLESHEET_BANNER = [
1023
+ const STYLESHEET_BASE_BANNER = [
1011
1024
  'Seeded by `canon teach stylesheet` from the design source in',
1012
- 'src/design/tokens.ts. The tokens and the two components below are the',
1013
- 'system this workspace renders in. Add lesson rules under them and read a',
1014
- 'value through its custom property rather than restating the hex, which is',
1015
- 'what let one workspace fork the palette from every other.',
1025
+ 'src/design/tokens.ts, and rewritten on every call. The tokens and the two',
1026
+ 'components below are the system this workspace renders in. Read a value',
1027
+ 'through its custom property rather than restating the hex, which is what',
1028
+ 'let one workspace fork the palette from every other. Add lesson rules to',
1029
+ `${TEACH_STYLESHEET} instead, which imports this file and is never rewritten.`,
1016
1030
  ].join('\n ')
1017
1031
 
1032
+ const stylesheetSeed = (): string =>
1033
+ `@import url('./${TEACH_STYLESHEET_BASE}');\n`
1034
+
1018
1035
  /**
1019
- * Writes a workspace's one stylesheet from the design source.
1036
+ * Writes a workspace's stylesheet pair from the design source.
1020
1037
  *
1021
- * Every workspace used to carry a hand-authored copy, which is how the course
1022
- * palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
1023
- * and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
1024
- * reach the file the first one wrote, and this is what puts the values in it.
1038
+ * Every workspace used to carry one hand-authored file, which is how the
1039
+ * course palette forked once per workspace. The names are fixed at
1040
+ * `TEACH_STYLESHEET` and `TEACH_STYLESHEET_BASE`, and the folder at
1041
+ * `TEACH_ASSETS`, for the same reason a second lesson has to reach the files
1042
+ * the first one wrote and names composed twice can differ.
1025
1043
  *
1026
- * An existing stylesheet is left alone rather than replaced. A workspace adds
1027
- * lesson rules to this file as it goes, so overwriting would discard them, and
1028
- * `--force` is the caller saying it wants the seed back.
1044
+ * `TEACH_STYLESHEET_BASE` carries the tokens and the components and is
1045
+ * rewritten every call, since nothing workspace-authored lives in it.
1046
+ * `TEACH_STYLESHEET` imports it, carries a workspace's own lesson rules, and
1047
+ * is written once and left alone, since overwriting it would discard them.
1048
+ * `--force` is the caller saying it wants that file's seed back too.
1029
1049
  */
1030
1050
  export async function writeStylesheet(
1031
1051
  root: string,
@@ -1036,22 +1056,39 @@ export async function writeStylesheet(
1036
1056
  if (!found.ok) return found
1037
1057
 
1038
1058
  const workspace = found.workspace
1039
- const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
1059
+ const assetsDir = join(workspace.path, TEACH_ASSETS)
1060
+ const baseRel = join(assetsDir, TEACH_STYLESHEET_BASE)
1061
+ const rel = join(assetsDir, TEACH_STYLESHEET)
1062
+ const basePath = join(root, baseRel)
1040
1063
  const path = join(root, rel)
1041
1064
 
1042
- if (existsSync(path) && !force) {
1043
- return { ok: true, slug: workspace.slug, path: rel, written: false }
1044
- }
1045
-
1046
- await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
1065
+ await mkdir(join(root, assetsDir), { recursive: true })
1047
1066
  await writeFile(
1048
- path,
1067
+ basePath,
1049
1068
  buildDesignCss(undefined, {
1050
- banner: STYLESHEET_BANNER,
1069
+ banner: STYLESHEET_BASE_BANNER,
1051
1070
  embedFonts: TEACH_FONT_FACES,
1052
1071
  components: TEACH_STYLESHEET_COMPONENTS,
1053
1072
  }),
1054
1073
  )
1055
1074
 
1056
- return { ok: true, slug: workspace.slug, path: rel, written: true }
1075
+ if (existsSync(path) && !force) {
1076
+ return {
1077
+ ok: true,
1078
+ slug: workspace.slug,
1079
+ path: rel,
1080
+ basePath: baseRel,
1081
+ written: false,
1082
+ }
1083
+ }
1084
+
1085
+ await writeFile(path, stylesheetSeed())
1086
+
1087
+ return {
1088
+ ok: true,
1089
+ slug: workspace.slug,
1090
+ path: rel,
1091
+ basePath: baseRel,
1092
+ written: true,
1093
+ }
1057
1094
  }
package/standards/plan.md CHANGED
@@ -61,6 +61,8 @@ The document opens with `# Feature: <short title>` and one paragraph stating wha
61
61
  - Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
62
62
  - Aim `## Summary` at a person scanning the plan, not at the session executing it. The other sections carry what execution needs.
63
63
  - Give every `**Files to touch:**` entry a backticked path and something said about it. A bare path states scope and not intent, and the reason is what an executing session checks its edit against. Lead with the path or lead with a label carrying the path, whichever reads better for the entry.
64
+ - Separate the two halves with a colon, and keep every path the entry declares ahead of it. What sits before the colon is the entry's subject and reads as a target, so both sides of a rename belong there and a file merely cited by the reason does not. A reason is free to name another path, and one entry that did was read as declaring a file its branch never wrote.
65
+ - Read the list as what the branch sets out to write rather than as a bound on it. The ship chain writes past it on nearly every branch, since the sync skills refresh whichever context entry and public doc the change reaches, and no planner can name those before the change exists. `canon tasks plan-reach <plan>` reads the branch back against this list and every other live plan, ahead of the pull request.
64
66
  - State every count and every claim about the tree as measured during the pass that wrote the plan. A figure carried in from a summary or an earlier session is the most common way a plan ships the wrong scope.
65
67
  - Prefer a short plan over a padded one. A section filled to look thorough costs the reader the same attention as one that matters.
66
68
 
@@ -67,6 +67,8 @@ Readiness is three groups under fixed headings, `## Run now`, `## Up next`, and
67
67
 
68
68
  Each group fixes its own columns, which follow from the test above it rather than from preference. Neither half of the `## Run now` test is checkable without the file set and the plan sitting beside the task.
69
69
 
70
+ A `Touches` cell is copied from its plan's own list, so it states what the branch sets out to write rather than a bound on it, and a branch outgrows it while the row still reads as it did at dispatch. Correct the cell from the branch rather than from the plan once one is running, since the plan is the prediction that already went stale and only the diff says what was written. `canon tasks plan-reach <plan>` reads that diff against every live plan and every cell in this group, and the row's owner is the one who writes the correction: a worker never edits this board.
71
+
70
72
  Row position inside `## Needs a plan` is the order those tasks get planned in, top first. The three tests answer whether a task can start, which is mechanical, and none of them answers which task is worth starting, which is a judgment no column holds. Position is where that judgment is recorded, so the top row is the answer to what to plan next and a reader needs no other surface to get it. The other two groups take the same reading, and it costs them little, since a group holding what is already planned is short by construction.
71
73
 
72
74
  Position alone carries it, and no rank column exists. A number beside each row is a second thing to keep in step with the order it duplicates, and the file is edited by one session at a time, so the order the rows are written in is already unambiguous. State on each row why it sits where it does, in the same cell that carries what it is waiting on. A position with no stated reason is re-derived from memory by the next session, which is the failure the ordering replaces rather than moves.
@@ -0,0 +1,103 @@
1
+ name: Deploy
2
+ # canon-no-seed: a stack-specific deploy workflow with no root counterpart by design, parameterized per project on sync rather than mirrored from one.
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+ paths:
9
+ # Fill in this project's own build inputs. A single glob covering the
10
+ # source tree is enough for most projects; widen it to cover every
11
+ # path the build reads when it draws on content living elsewhere.
12
+ - 'src/**'
13
+ workflow_dispatch:
14
+
15
+ # Fill in a concurrency group name unique to this project. A second push
16
+ # while a deploy is in flight publishes the newer commit rather than racing
17
+ # it, and the running job is left to finish so a half-uploaded site is never
18
+ # what a reader lands on.
19
+ concurrency:
20
+ group: deploy
21
+ cancel-in-progress: false
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ build:
28
+ name: 📦 Build
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - name: Checkout
32
+ uses: actions/checkout@v4
33
+
34
+ - name: Setup Bun
35
+ uses: oven-sh/setup-bun@v2
36
+ with:
37
+ bun-version: latest
38
+
39
+ - name: Install Dependencies
40
+ run: bun install --frozen-lockfile
41
+
42
+ - name: Build
43
+ run: bun run build
44
+
45
+ - name: Upload artifact
46
+ uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist
50
+ retention-days: 1
51
+
52
+ deploy:
53
+ name: 🚀 Deploy
54
+ needs: build
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - name: Check for Cloudflare secrets
58
+ id: secrets-check
59
+ env:
60
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
61
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
62
+ run: |
63
+ if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
64
+ echo "skip=true" >> "$GITHUB_OUTPUT"
65
+ else
66
+ echo "skip=false" >> "$GITHUB_OUTPUT"
67
+ fi
68
+
69
+ - name: Skip notice
70
+ if: steps.secrets-check.outputs.skip == 'true'
71
+ run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-cloudflare skill to finish setup."
72
+
73
+ # This job builds nothing and still needs a toolchain. The checkout
74
+ # below brings bun.lock, wrangler-action reads it, picks bun on the
75
+ # strength of it, and installs wrangler with that, so a deploy with no
76
+ # bun on PATH fails before it reaches Cloudflare. Dropping these steps
77
+ # as dead weight breaks the deploy on every push.
78
+ - name: Checkout
79
+ if: steps.secrets-check.outputs.skip == 'false'
80
+ uses: actions/checkout@v4
81
+
82
+ - name: Setup Bun
83
+ if: steps.secrets-check.outputs.skip == 'false'
84
+ uses: oven-sh/setup-bun@v2
85
+ with:
86
+ bun-version: latest
87
+
88
+ - name: Download artifact
89
+ if: steps.secrets-check.outputs.skip == 'false'
90
+ uses: actions/download-artifact@v4
91
+ with:
92
+ name: dist
93
+ path: dist
94
+
95
+ - name: Publish
96
+ if: steps.secrets-check.outputs.skip == 'false'
97
+ uses: cloudflare/wrangler-action@v3
98
+ with:
99
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
100
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
101
+ # Fill in this project's Pages project name, set when the
102
+ # deploy-cloudflare skill runs wrangler pages project create.
103
+ command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
@@ -0,0 +1,5 @@
1
+ [stack]
2
+ name = "cloudflare"
3
+ extends = ""
4
+ runtime = "bun"
5
+ scaffold = ""
@@ -0,0 +1,24 @@
1
+ # Tooling cloudflare reference
2
+
3
+ ## Overview
4
+
5
+ The cloudflare stack ships one parameterized GitHub Actions workflow that deploys a static build to Cloudflare Pages. It is keyed on host rather than on framework and carries no dependencies, seeds, or scripts of its own, so it syncs onto a project already carrying `astro`, `vite-react`, or any other stack at the same root without colliding.
6
+
7
+ ## Deploy workflow
8
+
9
+ - Separate `build` and `deploy` jobs, connected by an uploaded artifact, converged on the shape already running in the closest prior art rather than invented fresh.
10
+ - The deploy job checks for `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` before doing anything else, and skips with a `::notice::` rather than failing when either is absent. A first merge lands ahead of the Pages project existing, and this guard is what keeps that push green.
11
+ - `--branch=${{ github.ref_name }}` on every deploy, which gives every branch a Cloudflare preview URL rather than only `main`.
12
+ - The deploy job checks out the repository and installs Bun even though it builds nothing. `wrangler-action` resolves its package manager from `bun.lock` in the checkout, and dropping that step breaks the deploy on every push once the toolchain it relies on silently goes missing.
13
+
14
+ ## Parameters a project fills in by hand
15
+
16
+ Three values in the seeded file have no generic default and are set once, right after sync:
17
+
18
+ - The `paths:` filter under the `push` trigger. The shipped default is a single `src/**` glob. Widen it to cover every path the build reads when the build draws on catalogs or generated content living outside `src/`.
19
+ - The `concurrency.group` name. The shipped default is the bare string `deploy`, which is enough for a single-deploy project. Give it a project-specific name only if the project runs more than one deploy workflow.
20
+ - The `--project-name` flag on the `wrangler pages deploy` command. Set it to the name the `deploy-cloudflare` skill creates the Pages project under.
21
+
22
+ ## Re-sync
23
+
24
+ `configs/` is golden and always overwrites. Re-running `canon tooling sync cloudflare . --write` after hand-editing any of the three parameters above discards that edit and restores the shipped defaults. Re-fill the parameters after any re-sync rather than expecting the file to preserve them.