@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.
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +4 -0
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
- package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
- package/claude/skills/draft-and-pick/SKILL.md +10 -6
- package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
- package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
- package/claude/skills/git-ship/SKILL.md +17 -6
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
- package/claude/skills/role-planner/SKILL.md +5 -0
- package/claude/skills/role-worker/SKILL.md +6 -0
- package/claude/skills/session-relay/REQUIREMENT.md +41 -0
- package/claude/skills/session-relay/SKILL.md +36 -0
- package/claude/skills/ux-audit/SKILL.md +3 -0
- package/docs/agents/key-changes.md +5 -1
- package/docs/agents/tasks.md +33 -0
- package/docs/workflow/ai-workflow.md +2 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/ui/440-surface-capture.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/commands/pr.ts +128 -6
- package/src/commands/tasks.ts +154 -0
- package/src/commands/teach.ts +2 -0
- package/src/gate/measures.ts +66 -0
- package/src/gate/stages.ts +11 -0
- package/src/git-files.ts +69 -0
- package/src/migrate/skill-names.ts +10 -0
- package/src/pr/bijection.ts +59 -1
- package/src/tasks/reach.ts +383 -0
- package/src/teach/workspace.ts +60 -23
- package/standards/plan.md +2 -0
- package/standards/tasks.md +2 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
- package/tooling/cloudflare/manifest.toml +5 -0
- 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
|
+
}
|
package/src/teach/workspace.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
1013
|
-
'system this workspace renders in.
|
|
1014
|
-
'
|
|
1015
|
-
'
|
|
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
|
|
1036
|
+
* Writes a workspace's stylesheet pair from the design source.
|
|
1020
1037
|
*
|
|
1021
|
-
* Every workspace used to carry
|
|
1022
|
-
* palette forked once per workspace. The
|
|
1023
|
-
* and
|
|
1024
|
-
*
|
|
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
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1067
|
+
basePath,
|
|
1049
1068
|
buildDesignCss(undefined, {
|
|
1050
|
-
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
|
-
|
|
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
|
|
package/standards/tasks.md
CHANGED
|
@@ -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,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.
|