@erclx/canon 5.16.0 → 5.18.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/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +9 -9
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/demo.ts +2 -0
- package/src/commands/teach.ts +3 -1
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/teach/workspace.ts +61 -6
- package/standards/architecture.md +13 -7
- package/standards/figures.md +1 -0
- package/standards/requirements.md +4 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
package/src/demo/drive.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { deriveSteps } from '@/demo/compile'
|
|
|
8
8
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
9
9
|
import type { CursorSet } from '@/demo/pointer'
|
|
10
10
|
import { pointerSource } from '@/demo/pointer'
|
|
11
|
+
import type { StepTiming, TargetBox } from '@/demo/timeline'
|
|
12
|
+
import { timelineEntry, timelinePath, writeTimeline } from '@/demo/timeline'
|
|
11
13
|
|
|
12
14
|
declare global {
|
|
13
15
|
interface Window {
|
|
@@ -84,6 +86,7 @@ export type DriveResult =
|
|
|
84
86
|
status: 'recorded'
|
|
85
87
|
videoPath?: string
|
|
86
88
|
stillPath?: string
|
|
89
|
+
timelinePath?: string
|
|
87
90
|
steps: number
|
|
88
91
|
durationMs: number
|
|
89
92
|
}
|
|
@@ -150,6 +153,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
150
153
|
|
|
151
154
|
let stillPath: string | undefined
|
|
152
155
|
let videoPath: string | undefined
|
|
156
|
+
let timeline: string | undefined
|
|
153
157
|
|
|
154
158
|
try {
|
|
155
159
|
await context.addInitScript({
|
|
@@ -157,7 +161,12 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
157
161
|
})
|
|
158
162
|
await context.addInitScript({ content: captionInitScript() })
|
|
159
163
|
const page = await context.newPage()
|
|
164
|
+
// The recording begins with the page, so this is the instant a frame of
|
|
165
|
+
// the take is zero. Taking it at the first step instead would drift every
|
|
166
|
+
// entry by the opening page load.
|
|
167
|
+
const recordingStart = performance.now()
|
|
160
168
|
const video = page.video()
|
|
169
|
+
const timings: StepTiming[] = []
|
|
161
170
|
const pace: PointerPace = {}
|
|
162
171
|
|
|
163
172
|
// The opening navigate is skipped when the plan already starts with one,
|
|
@@ -170,8 +179,16 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
for (const step of plan.steps) {
|
|
174
|
-
|
|
182
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
183
|
+
const startedAt = performance.now()
|
|
184
|
+
const box = await runStep(page, plan, step, pace)
|
|
185
|
+
timings.push({
|
|
186
|
+
index,
|
|
187
|
+
kind: step.kind,
|
|
188
|
+
startedAt,
|
|
189
|
+
endedAt: performance.now(),
|
|
190
|
+
...(box ? { box } : {}),
|
|
191
|
+
})
|
|
175
192
|
// The first marked step wins. One file holds one frame, so a plan a
|
|
176
193
|
// person edited to mark several would otherwise write each over the last
|
|
177
194
|
// and keep whichever ran last, with nothing saying so.
|
|
@@ -191,6 +208,11 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
191
208
|
// The engine keeps the auto-named recording beside the saved copy, so a
|
|
192
209
|
// run that skipped this would leave two files for every demo.
|
|
193
210
|
await video.delete()
|
|
211
|
+
timeline = timelinePath(videoPath)
|
|
212
|
+
writeTimeline(
|
|
213
|
+
timeline,
|
|
214
|
+
timings.map((timing) => timelineEntry(timing, recordingStart)),
|
|
215
|
+
)
|
|
194
216
|
}
|
|
195
217
|
} catch (error) {
|
|
196
218
|
return failed('drive-failed', error)
|
|
@@ -203,6 +225,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
203
225
|
status: 'recorded',
|
|
204
226
|
...(videoPath ? { videoPath } : {}),
|
|
205
227
|
...(stillPath ? { stillPath } : {}),
|
|
228
|
+
...(timeline ? { timelinePath: timeline } : {}),
|
|
206
229
|
steps: plan.steps.length,
|
|
207
230
|
durationMs: Date.now() - started,
|
|
208
231
|
}
|
|
@@ -237,13 +260,17 @@ export interface PointerPace {
|
|
|
237
260
|
* Exported so `drive.e2e.test.ts` can drive one real step against a real
|
|
238
261
|
* caption and read it back, which is the integration a full `drive()` call
|
|
239
262
|
* cannot assert without decoding the video it writes.
|
|
263
|
+
*
|
|
264
|
+
* Returns the box the pointer moved to, for the timeline, and nothing for
|
|
265
|
+
* a step that points at no target.
|
|
240
266
|
*/
|
|
241
267
|
export async function runStep(
|
|
242
268
|
page: Page,
|
|
243
269
|
plan: DemoPlan,
|
|
244
270
|
step: DemoStep,
|
|
245
271
|
pace: PointerPace,
|
|
246
|
-
): Promise<
|
|
272
|
+
): Promise<TargetBox | undefined> {
|
|
273
|
+
let box: TargetBox | undefined
|
|
247
274
|
switch (step.kind) {
|
|
248
275
|
case 'navigate':
|
|
249
276
|
await page.goto(step.target || plan.url)
|
|
@@ -252,18 +279,18 @@ export async function runStep(
|
|
|
252
279
|
}
|
|
253
280
|
break
|
|
254
281
|
case 'click':
|
|
255
|
-
await moveTo(page, plan, step, pace)
|
|
282
|
+
box = await moveTo(page, plan, step, pace)
|
|
256
283
|
await page.mouse.down()
|
|
257
284
|
await page.mouse.up()
|
|
258
285
|
break
|
|
259
286
|
case 'fill':
|
|
260
|
-
await moveTo(page, plan, step, pace)
|
|
287
|
+
box = await moveTo(page, plan, step, pace)
|
|
261
288
|
await page.mouse.down()
|
|
262
289
|
await page.mouse.up()
|
|
263
290
|
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
264
291
|
break
|
|
265
292
|
case 'hover':
|
|
266
|
-
await moveTo(page, plan, step, pace)
|
|
293
|
+
box = await moveTo(page, plan, step, pace)
|
|
267
294
|
break
|
|
268
295
|
case 'scroll':
|
|
269
296
|
// Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
|
|
@@ -307,6 +334,7 @@ export async function runStep(
|
|
|
307
334
|
await setCaption(page, step.caption)
|
|
308
335
|
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
309
336
|
await page.waitForTimeout(step.holdMs)
|
|
337
|
+
return box
|
|
310
338
|
}
|
|
311
339
|
|
|
312
340
|
/**
|
|
@@ -333,7 +361,7 @@ async function moveTo(
|
|
|
333
361
|
plan: DemoPlan,
|
|
334
362
|
step: DemoStep,
|
|
335
363
|
pace: PointerPace,
|
|
336
|
-
): Promise<
|
|
364
|
+
): Promise<TargetBox> {
|
|
337
365
|
pace.roundTripMs ??= await calibrateRoundTrip(page)
|
|
338
366
|
const locator = page.locator(step.target).first()
|
|
339
367
|
await locator.waitFor()
|
|
@@ -342,6 +370,7 @@ async function moveTo(
|
|
|
342
370
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
343
371
|
steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
|
|
344
372
|
})
|
|
373
|
+
return box
|
|
345
374
|
}
|
|
346
375
|
|
|
347
376
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
import type { DemoStep } from '@/demo/compile'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A step's target in viewport pixels, which are the video frame's own
|
|
7
|
+
* coordinates because the recording is sized to the viewport.
|
|
8
|
+
*/
|
|
9
|
+
export interface TargetBox {
|
|
10
|
+
readonly x: number
|
|
11
|
+
readonly y: number
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One step as the run clocked it, in wall-clock milliseconds. */
|
|
17
|
+
export interface StepTiming {
|
|
18
|
+
readonly index: number
|
|
19
|
+
readonly kind: DemoStep['kind']
|
|
20
|
+
readonly startedAt: number
|
|
21
|
+
readonly endedAt: number
|
|
22
|
+
readonly box?: TargetBox
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Times count from the recording's start rather than the first step, since
|
|
27
|
+
* that is the clock a composition places the take on. A step ends once its
|
|
28
|
+
* hold has played, so the span covers what the viewer sees of it.
|
|
29
|
+
*/
|
|
30
|
+
export interface TimelineEntry {
|
|
31
|
+
readonly index: number
|
|
32
|
+
readonly kind: DemoStep['kind']
|
|
33
|
+
readonly startMs: number
|
|
34
|
+
readonly endMs: number
|
|
35
|
+
readonly box?: TargetBox
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function timelineEntry(
|
|
39
|
+
timing: StepTiming,
|
|
40
|
+
recordingStart: number,
|
|
41
|
+
): TimelineEntry {
|
|
42
|
+
return {
|
|
43
|
+
index: timing.index,
|
|
44
|
+
kind: timing.kind,
|
|
45
|
+
startMs: Math.round(timing.startedAt - recordingStart),
|
|
46
|
+
endMs: Math.round(timing.endedAt - recordingStart),
|
|
47
|
+
...(timing.box ? { box: timing.box } : {}),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function timelinePath(videoPath: string): string {
|
|
52
|
+
return join(
|
|
53
|
+
dirname(videoPath),
|
|
54
|
+
`${basename(videoPath, extname(videoPath))}.timeline.json`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeTimeline(
|
|
59
|
+
path: string,
|
|
60
|
+
entries: readonly TimelineEntry[],
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
63
|
+
writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`)
|
|
64
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
isOverCount,
|
|
12
12
|
isOverLength,
|
|
13
|
+
isOverRisks,
|
|
13
14
|
measureArchitecture,
|
|
14
15
|
missingRevisit,
|
|
16
|
+
overWords,
|
|
15
17
|
} from '@/context/architecture'
|
|
18
|
+
import { isOverWordCap, measureRequirements } from '@/context/requirements'
|
|
16
19
|
import {
|
|
17
20
|
auditSkills,
|
|
18
21
|
PRACTICE_LEDGER,
|
|
@@ -246,42 +249,80 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
|
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
/**
|
|
249
|
-
* The
|
|
250
|
-
* line ceiling its allowances derive,
|
|
251
|
-
* closing each decision
|
|
252
|
+
* The canonical records against the caps each states for itself. The
|
|
253
|
+
* architecture record carries five: the line ceiling its allowances derive,
|
|
254
|
+
* the entry cap, the revisit sentence closing each decision, the words a
|
|
255
|
+
* decision may hold, and the bullets its Risks section may hold. The
|
|
256
|
+
* requirements record carries one, its own word count.
|
|
252
257
|
*
|
|
253
258
|
* Read in-process rather than through `context audit`, whose one gating stage
|
|
254
|
-
* here runs `--citations-only` and never opens
|
|
255
|
-
* line ceiling went unenforced by `bun run check` until this stage. A
|
|
256
|
-
* carrying
|
|
257
|
-
* each rule belongs to the record rather than to the toolkit.
|
|
259
|
+
* here runs `--citations-only` and never opens either record, which is why
|
|
260
|
+
* the line ceiling went unenforced by `bun run check` until this stage. A
|
|
261
|
+
* project carrying neither record, or records stating none of the caps,
|
|
262
|
+
* passes, since each rule belongs to the record rather than to the toolkit.
|
|
258
263
|
*/
|
|
259
|
-
export const
|
|
260
|
-
const report = await
|
|
264
|
+
export const canonicalRecords: Measure = async (ctx) => {
|
|
265
|
+
const [report, requirements] = await Promise.all([
|
|
266
|
+
measureArchitecture(ctx.root),
|
|
267
|
+
measureRequirements(ctx.root),
|
|
268
|
+
])
|
|
269
|
+
|
|
270
|
+
const failures: string[] = []
|
|
271
|
+
const emissions: Emission[] = []
|
|
272
|
+
|
|
261
273
|
if (report === undefined) {
|
|
262
|
-
|
|
274
|
+
emissions.push(info('No architecture record to measure'))
|
|
275
|
+
} else {
|
|
276
|
+
const decisions = report.decisions.length
|
|
277
|
+
const lackingRevisit = missingRevisit(report)
|
|
278
|
+
const longDecisions = overWords(report)
|
|
279
|
+
failures.push(
|
|
280
|
+
...[
|
|
281
|
+
isOverCount(report) &&
|
|
282
|
+
`${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
|
|
283
|
+
isOverLength(report) &&
|
|
284
|
+
`${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
|
|
285
|
+
lackingRevisit.length > 0 &&
|
|
286
|
+
`No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
|
|
287
|
+
longDecisions.length > 0 &&
|
|
288
|
+
`${longDecisions.map((entry) => `"${entry.heading}" (${entry.words})`).join(', ')} past ${report.wordCap} words a decision in ${report.rel}. Cut the reasoning to the trade, never split one decision across two headings.`,
|
|
289
|
+
isOverRisks(report) &&
|
|
290
|
+
`${report.risksBullets} risk bullets against a cap of ${report.riskCap} in ${report.rel}. Drop what is no longer open.`,
|
|
291
|
+
].filter((failure): failure is string => typeof failure === 'string'),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const cap =
|
|
295
|
+
report.entryCap === undefined
|
|
296
|
+
? 'no entry cap stated'
|
|
297
|
+
: `a cap of ${report.entryCap}`
|
|
298
|
+
emissions.push(info(`${decisions} decisions against ${cap}`))
|
|
299
|
+
if (report.riskCap !== undefined && report.risksBullets === undefined) {
|
|
300
|
+
emissions.push(
|
|
301
|
+
info(
|
|
302
|
+
`No Risks section to count against a cap of ${report.riskCap} in ${report.rel}`,
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
263
306
|
}
|
|
264
307
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`${
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
308
|
+
if (requirements === undefined) {
|
|
309
|
+
emissions.push(info('No requirements record to measure'))
|
|
310
|
+
} else if (isOverWordCap(requirements)) {
|
|
311
|
+
failures.push(
|
|
312
|
+
`${requirements.words} words against a cap of ${requirements.wordCap} in ${requirements.rel}.`,
|
|
313
|
+
)
|
|
314
|
+
} else {
|
|
315
|
+
const cap =
|
|
316
|
+
requirements.wordCap === undefined
|
|
317
|
+
? 'no word cap stated'
|
|
318
|
+
: `a cap of ${requirements.wordCap}`
|
|
319
|
+
emissions.push(
|
|
320
|
+
info(`${requirements.words} requirements words against ${cap}`),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
275
323
|
|
|
276
324
|
if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
|
|
277
|
-
|
|
278
|
-
const cap =
|
|
279
|
-
report.entryCap === undefined
|
|
280
|
-
? 'no entry cap stated'
|
|
281
|
-
: `a cap of ${report.entryCap}`
|
|
282
|
-
return {
|
|
283
|
-
emissions: [info(`${decisions} decisions against ${cap}`)],
|
|
284
|
-
}
|
|
325
|
+
return { emissions }
|
|
285
326
|
}
|
|
286
327
|
|
|
287
328
|
/**
|
package/src/gate/stages.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
skillPaths,
|
|
7
7
|
} from '@/gate/boundaries'
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
canonicalRecords,
|
|
10
10
|
auditSet,
|
|
11
11
|
captureStamps,
|
|
12
12
|
clientCommandCitations,
|
|
@@ -346,11 +346,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
346
346
|
success: 'Context citations resolve',
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
|
-
//
|
|
349
|
+
// Each record's caps are facts it states about itself, unlike the
|
|
350
350
|
// judgment thresholds the stage above leaves out, so they gate here.
|
|
351
|
-
id: '
|
|
352
|
-
label: '
|
|
353
|
-
checks: [{ kind: 'measure', measure:
|
|
351
|
+
id: 'canonical-records',
|
|
352
|
+
label: 'Canonical records',
|
|
353
|
+
checks: [{ kind: 'measure', measure: canonicalRecords }],
|
|
354
354
|
},
|
|
355
355
|
{
|
|
356
356
|
// Unscoped, since any branch can grow any document. A document past the
|
package/src/teach/workspace.ts
CHANGED
|
@@ -885,12 +885,60 @@ async function openFile(
|
|
|
885
885
|
return path
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
/** The entry lines of the file, or of one heading's section. */
|
|
889
|
+
function listedEntries(text: string, heading?: string): string[] {
|
|
890
|
+
const body = unfenced(text)
|
|
891
|
+
const section = heading
|
|
892
|
+
? sectionRange(body, heading, text.split('\n').length)
|
|
893
|
+
: undefined
|
|
894
|
+
if (heading && !section) return []
|
|
895
|
+
|
|
896
|
+
return body
|
|
897
|
+
.filter(
|
|
898
|
+
(line) =>
|
|
899
|
+
isEntry(line.text) &&
|
|
900
|
+
(!section ||
|
|
901
|
+
(line.number - 1 >= section.start && line.number - 1 < section.end)),
|
|
902
|
+
)
|
|
903
|
+
.map((line) => line.text)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function lists(entries: readonly string[], source: Source): boolean {
|
|
907
|
+
return entries.some((line) => line.includes(`(${source.url})`))
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Drops the lead entries naming the given URLs, wrapped continuation lines
|
|
912
|
+
* included, and restores the placeholder when the section empties.
|
|
913
|
+
*/
|
|
914
|
+
function withoutLeads(text: string, urls: ReadonlySet<string>): string {
|
|
915
|
+
const lines = text.split('\n')
|
|
916
|
+
const section = sectionRange(unfenced(text), LEADS_HEADING, lines.length)
|
|
917
|
+
if (!section) return text
|
|
918
|
+
|
|
919
|
+
const bullets = bulletRange(unfenced(text), section)
|
|
920
|
+
if (!bullets) return text
|
|
921
|
+
|
|
922
|
+
const kept = bulletBlocks(lines.slice(bullets.start, bullets.end)).filter(
|
|
923
|
+
(block) => ![...urls].some((url) => block[0].includes(`(${url})`)),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
return [
|
|
927
|
+
...lines.slice(0, bullets.start),
|
|
928
|
+
...(kept.length > 0 ? kept.flat() : [PLACEHOLDER]),
|
|
929
|
+
...lines.slice(bullets.end),
|
|
930
|
+
].join('\n')
|
|
931
|
+
}
|
|
932
|
+
|
|
888
933
|
/**
|
|
889
934
|
* Appends sources to `RESOURCES.md`, keeping what was read apart from what was
|
|
890
935
|
* only found.
|
|
891
936
|
*
|
|
892
937
|
* A URL already listed under either heading is refused rather than repeated,
|
|
893
938
|
* since a second entry for one source splits what rests on it across two lines.
|
|
939
|
+
* The one exception is a read source found under `## Leads`, which moves to
|
|
940
|
+
* `## Read` under its new title, since opening a lead is how it becomes read.
|
|
941
|
+
* Any refusal leaves the file untouched, so a batch lands whole or not at all.
|
|
894
942
|
*/
|
|
895
943
|
export async function recordSources(
|
|
896
944
|
root: string,
|
|
@@ -905,13 +953,16 @@ export async function recordSources(
|
|
|
905
953
|
if (typeof path !== 'string') return path
|
|
906
954
|
|
|
907
955
|
let text = await readFile(path, 'utf8')
|
|
908
|
-
const listed =
|
|
909
|
-
|
|
910
|
-
.filter(isEntry)
|
|
956
|
+
const listed = listedEntries(text)
|
|
957
|
+
const leadEntries = listedEntries(text, LEADS_HEADING)
|
|
911
958
|
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
959
|
+
const isPromotion = (source: Source) => lists(leadEntries, source)
|
|
960
|
+
const promoted = read.filter(isPromotion)
|
|
961
|
+
|
|
962
|
+
const repeated = [
|
|
963
|
+
...read.filter((source) => !isPromotion(source)),
|
|
964
|
+
...leads,
|
|
965
|
+
].filter((source) => lists(listed, source))
|
|
915
966
|
|
|
916
967
|
if (repeated.length > 0) {
|
|
917
968
|
return refuse(
|
|
@@ -921,6 +972,10 @@ export async function recordSources(
|
|
|
921
972
|
)
|
|
922
973
|
}
|
|
923
974
|
|
|
975
|
+
if (promoted.length > 0) {
|
|
976
|
+
text = withoutLeads(text, new Set(promoted.map((source) => source.url)))
|
|
977
|
+
}
|
|
978
|
+
|
|
924
979
|
for (const [heading, sources] of [
|
|
925
980
|
[READ_HEADING, read],
|
|
926
981
|
[LEADS_HEADING, leads],
|
|
@@ -73,18 +73,24 @@ A decision says why it won and rarely says when that reason stops holding, so an
|
|
|
73
73
|
- Draft it from the harm the decision prevents, never from the mechanism it uses. A mechanism can keep working long after the reason for choosing it is gone, so re-checking it confirms the wrong fact.
|
|
74
74
|
- Place the sentence after the reasoning and before any verification anchor, so the anchor stays the entry's last sentence.
|
|
75
75
|
- Give a heading holding several principles one sentence, for the premise they share, rather than one per principle.
|
|
76
|
-
- Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the
|
|
76
|
+
- Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the caps, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
|
|
77
77
|
- A checker reads presence alone. A vacuous sentence passes it, so the sentence is only as useful as the finding it names.
|
|
78
78
|
|
|
79
|
-
##
|
|
79
|
+
## Caps
|
|
80
80
|
|
|
81
|
-
Every session pays for this file before any work starts, so a heavy read is a real cost.
|
|
81
|
+
Every session pays for this file before any work starts, so a heavy read is a real cost. Weight comes from two places, too many decisions and decisions written too long, so the record caps both, and caps the open risks beside them.
|
|
82
82
|
|
|
83
|
-
- State
|
|
84
|
-
-
|
|
85
|
-
-
|
|
83
|
+
- State each cap in the record itself. A checker reads only these spellings, so a clause worded another way is reported and never gated:
|
|
84
|
+
- `This record holds at most <n> decisions.`
|
|
85
|
+
- `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
|
|
86
|
+
- `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
|
|
87
|
+
- The three can share one sentence, such as `This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.` Each cap is the record's own, so a record stating none is measured and never gated, and the template states the entry cap alone so the other two are adopted on purpose.
|
|
88
|
+
- Set the decision cap near one decision per slot plus a small margin, the word cap near what one trade takes to state, about 150, and the risk cap at what a reader holds in mind at once, about 6.
|
|
89
|
+
- At the decision cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never pack two decisions under one heading, which the count cannot see.
|
|
90
|
+
- At the word cap, cut a decision to the choice, the alternative that lost, the reason, and the revisit sentence. What it sheds is a mechanism or a measurement, which goes to the domain context entry it constrains, never under a second heading.
|
|
91
|
+
- At the risk cap, drop what is no longer open before adding a risk.
|
|
86
92
|
- Retire a decision by moving it to the domain context entry it constrains, not by deleting its reasoning.
|
|
87
|
-
-
|
|
93
|
+
- The whole file's word count stays a report, read alongside the judgment, and never gates.
|
|
88
94
|
- Yield to the paragraph weight checkpoint in `markdown.md`. A paragraph past the checkpoint is a defect no length guideline licenses.
|
|
89
95
|
|
|
90
96
|
## Template
|
package/standards/figures.md
CHANGED
|
@@ -38,6 +38,7 @@ A figure failing these is non-conforming even when it satisfies every shape rule
|
|
|
38
38
|
|
|
39
39
|
- Route a graph-shaped subject, one whose relationship, boundary, or path a flowchart already expresses, through Mermaid's own hand-drawn look rather than through model-authored SVG coordinates. `mermaid.md` governs the fence itself.
|
|
40
40
|
- Reserve freehand inline SVG, plain shapes and lines authored directly in the markup, for a subject that is not graph-shaped. A figure that cannot express its subject through the deterministic path is a worse failure than an inconsistent one drawn by hand, so the hatch stays open rather than forcing every figure through the renderer.
|
|
41
|
+
- Draw a figure in a teach lesson freehand whatever its shape. A lesson letters its figures in a hand font the renderer cannot load, so Mermaid measures every label in a narrower fallback face, and the page then clips the wider text it actually shows.
|
|
41
42
|
- State this as a render-first policy rather than a ban on a tool-exported drawing. Routing a graph-shaped figure through Mermaid is itself running it through an external tool, so a rule banning any diagram "exported from a diagramming tool" would forbid the path this policy requires.
|
|
42
43
|
|
|
43
44
|
## Wrapping and captioning
|
|
@@ -36,6 +36,10 @@ Does not govern:
|
|
|
36
36
|
|
|
37
37
|
Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, and `## Constraints`. Add `## Distribution` when the rule below applies. Add `## Worldview` when the project holds beliefs that shape every decision, and keep a belief there only when it changes a decision, since a padded worldview turns into a second goals list. Add `## Premise` when the project exists to answer a question, stated as the question and what would count as an answer, never as the answer measured so far. Drop a section rather than pad it with filler.
|
|
38
38
|
|
|
39
|
+
## Length
|
|
40
|
+
|
|
41
|
+
This file loads into every session, so its weight is paid before any work starts. Cap it by stating the clause `This record holds at most <n> words.` in the record itself, where a checker reads it and counts every word below the frontmatter. The cap is the record's own, so a record stating none is measured and never gated. Set it near 600, and at the cap cut a goal or a constraint that no longer changes a decision before adding one.
|
|
42
|
+
|
|
39
43
|
## Lifecycle
|
|
40
44
|
|
|
41
45
|
Once every entry in the MVP list, or in a later scope section, has shipped, delete the section. Git keeps the old text, and the document states the project as it stands. Do not annotate an entry as shipped and leave it in place. While any entry is unshipped, do not renumber the list or append to it.
|
|
@@ -91,6 +91,6 @@ The first entry covers the whole record root, so a record folder added later nee
|
|
|
91
91
|
|
|
92
92
|
## settings.json
|
|
93
93
|
|
|
94
|
-
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships
|
|
94
|
+
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships three PreToolUse hooks (pairing with `.claude/hooks/index-reminder.sh`, `.claude/hooks/search-reminder.sh`, and `.claude/hooks/scratch-guard.sh`) and a PostToolUse hook (pairs with `.claude/hooks/standards-audit.sh`). Project settings layer over user settings, so per-project files only carry what is genuinely project-specific.
|
|
95
95
|
- User-level template at `tooling/claude/user/settings.template.json` carries `attribution.commit` and `attribution.pr` (empty strings to suppress co-author tags), `permissions.allow` with `Bash(bun run *)`, and `permissions.deny` with `Read(**/.env)` and `Read(**/.env.*)` to block accidental secret exposure across any project. Installed once per machine via `canon claude setup`.
|
|
96
96
|
- The deprecated `includeCoAuthoredBy` key is not used.
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
# and no error appears. This computes the fact a session was asking itself
|
|
9
9
|
# to infer on every response and hands the answer back instead.
|
|
10
10
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
# the environment variables Claude Code documents as passed to a hook
|
|
14
|
-
# subprocess, unlike CLAUDE_PROJECT_DIR, so a hook cannot read it reliably.
|
|
11
|
+
# The link form a response uses comes from the harness's own instruction for
|
|
12
|
+
# its surface, not from this hook or the output rule.
|
|
15
13
|
#
|
|
16
14
|
# The worktree branch comes from the path rather than from `git rev-parse`,
|
|
17
15
|
# the way `tasks-index.sh` and `memory-index.sh` already derive their main
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Claude Code sends a payload and closes stdin. A bare read with nothing feeding
|
|
4
|
+
# it blocks forever and holds the session open, so the read is bounded. `read`
|
|
5
|
+
# rather than `timeout cat`, which macOS does not ship.
|
|
6
|
+
IFS= read -r -d '' -t 2 input
|
|
7
|
+
[ -n "$input" ] || {
|
|
8
|
+
printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
|
|
9
|
+
exit 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
13
|
+
[ "$tool" = "WebSearch" ] || exit 0
|
|
14
|
+
|
|
15
|
+
# Without a session id there is no stable key to dedupe on, and a shared marker
|
|
16
|
+
# would silence every later session, so stay quiet rather than guess one.
|
|
17
|
+
session=$(printf '%s' "$input" | jq -r '.session_id // empty')
|
|
18
|
+
[ -n "$session" ] || exit 0
|
|
19
|
+
|
|
20
|
+
key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
|
|
21
|
+
# The marker is scratch, so it follows the scratch folder to whichever record
|
|
22
|
+
# root the project carries rather than creating a second one beside it.
|
|
23
|
+
project="${CLAUDE_PROJECT_DIR:-.}"
|
|
24
|
+
if [ -d "$project/.canon" ]; then
|
|
25
|
+
marker_dir="$project/.canon/tmp/hooks/search-reminder"
|
|
26
|
+
else
|
|
27
|
+
marker_dir="$project/.claude/.tmp/hooks/search-reminder"
|
|
28
|
+
fi
|
|
29
|
+
marker="$marker_dir/$key"
|
|
30
|
+
[ -f "$marker" ] && exit 0
|
|
31
|
+
mkdir -p "$marker_dir"
|
|
32
|
+
: >"$marker"
|
|
33
|
+
|
|
34
|
+
msg='Load the canon:search-craft skill before this web search. Report it rather than proceeding silently when the skill does not resolve.'
|
|
35
|
+
jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
|
|
@@ -75,7 +75,7 @@ jobs:
|
|
|
75
75
|
|
|
76
76
|
- name: Skip notice
|
|
77
77
|
if: steps.secrets-check.outputs.skip == 'true'
|
|
78
|
-
run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-
|
|
78
|
+
run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-app skill to finish setup."
|
|
79
79
|
|
|
80
80
|
# This job builds nothing and still needs a toolchain. The checkout
|
|
81
81
|
# below brings bun.lock, wrangler-action reads it, picks bun on the
|
|
@@ -110,7 +110,7 @@ jobs:
|
|
|
110
110
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
111
111
|
wranglerVersion: '3.90.0'
|
|
112
112
|
# Fill in this project's Pages project name, set when the
|
|
113
|
-
# deploy-
|
|
113
|
+
# deploy-app skill runs wrangler pages project create.
|
|
114
114
|
command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
|
|
115
115
|
|
|
116
116
|
# `canon pr preview` reads this line out of the run log, so the marker is
|
|
@@ -20,7 +20,7 @@ Three values in the seeded file have no generic default and are set once, right
|
|
|
20
20
|
|
|
21
21
|
- 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/`.
|
|
22
22
|
- The `concurrency.group` name. The shipped default is `deploy-${{ github.ref_name }}`, which is enough for a single-deploy project. Give the `deploy` prefix a project-specific name only if the project runs more than one deploy workflow, and keep the ref suffix.
|
|
23
|
-
- The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-
|
|
23
|
+
- The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-app` skill creates the Pages project under.
|
|
24
24
|
|
|
25
25
|
## Re-sync
|
|
26
26
|
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: deploy-cloudflare
|
|
3
|
-
description: What the Cloudflare Pages setup gap is, which of the four manual steps this skill closes, and why the credential and the custom domain stay the operator's
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Deploy Cloudflare requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, a Cloudflare Pages setup is a sequence performed from memory. Three of its four repeated acts touch a service rather than the tree, so no workflow file can absorb them, and one of the acts has already cost a broken deploy: the account ID displayed in the Cloudflare dashboard is truncated, and a value copied from there is wrong in a way that only shows up at deploy time.
|
|
11
|
-
|
|
12
|
-
## Must
|
|
13
|
-
|
|
14
|
-
- Create the Pages project through `wrangler pages project create`
|
|
15
|
-
- Fetch the account ID from `wrangler`'s own authenticated session rather than have it typed or pasted
|
|
16
|
-
- Stop for the operator to issue the API token and run `gh secret set CLOUDFLARE_API_TOKEN` themselves
|
|
17
|
-
- Verify both secrets are present with `gh secret list` before continuing
|
|
18
|
-
- Stop for the operator to attach the custom domain in the dashboard
|
|
19
|
-
- Call `wrangler` and `gh` directly for every step those tools already cover
|
|
20
|
-
|
|
21
|
-
## Must not
|
|
22
|
-
|
|
23
|
-
- Accept a Cloudflare API token as input in any form. The toolkit verifies a secret exists, never what it contains.
|
|
24
|
-
- Reimplement `wrangler pages project create` or `gh secret set` as a hand-built API call
|
|
25
|
-
- Open a pull request or merge
|
|
26
|
-
|
|
27
|
-
## Guards
|
|
28
|
-
|
|
29
|
-
- `wrangler` not authenticated: stop and name the login command
|
|
30
|
-
- `gh` not authenticated: stop and name the login command
|
|
31
|
-
- A secret missing after the token stop: stop and name which one
|
|
32
|
-
|
|
33
|
-
## Out of scope
|
|
34
|
-
|
|
35
|
-
- Attaching a custom domain through the Cloudflare REST API instead of the dashboard. Measured absent from `wrangler pages` at plan time and the REST API was not read, so the dashboard stop stays for this pass.
|
|
36
|
-
- Running the deploy itself once secrets and the domain are set. That is the seeded `tooling/cloudflare/configs/.github/workflows/deploy.yml` workflow, triggered by a push to main.
|
|
37
|
-
- Reconfiguring an existing Pages project's build settings.
|