@erclx/canon 4.115.0 → 4.117.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/review-pr/REQUIREMENT.md +2 -0
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/commands.md +3 -1
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/skills-audit.md +8 -1
- package/docs/agents/teach.md +20 -4
- package/docs/workflow/ai-workflow.md +9 -9
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/markdown.ts +63 -2
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +18 -3
- package/src/context/audit.ts +6 -8
- package/src/gate/measures.ts +60 -0
- package/src/gate/stages.ts +12 -2
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +75 -7
- package/src/teach/render.ts +94 -6
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
|
@@ -4,9 +4,26 @@ import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
|
4
4
|
export interface ParagraphProps {
|
|
5
5
|
/** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
|
|
6
6
|
readonly lede?: boolean
|
|
7
|
+
/** Reference numbers, each linking to the `Refs` item carrying that id. */
|
|
8
|
+
readonly cites?: readonly number[]
|
|
7
9
|
readonly children: Child
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export function Paragraph({ lede, children }: ParagraphProps): Element {
|
|
11
|
-
|
|
12
|
+
export function Paragraph({ lede, cites, children }: ParagraphProps): Element {
|
|
13
|
+
const markers = (cites ?? []).map((cite) => (
|
|
14
|
+
<sup class="cite">
|
|
15
|
+
<a href={`#r${cite}`}>{cite}</a>
|
|
16
|
+
</sup>
|
|
17
|
+
))
|
|
18
|
+
return lede ? (
|
|
19
|
+
<p class="lede">
|
|
20
|
+
{children}
|
|
21
|
+
{markers}
|
|
22
|
+
</p>
|
|
23
|
+
) : (
|
|
24
|
+
<p>
|
|
25
|
+
{children}
|
|
26
|
+
{markers}
|
|
27
|
+
</p>
|
|
28
|
+
)
|
|
12
29
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface Reference {
|
|
5
|
+
readonly title: string
|
|
6
|
+
readonly url?: string
|
|
7
|
+
readonly note?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RefsProps {
|
|
11
|
+
readonly items: readonly Reference[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Numbers each item from 1 so its `id` matches the `#r<n>` a cited
|
|
16
|
+
* `Paragraph` links to. The caller has already checked every `url` parses as
|
|
17
|
+
* http or https, since attribute escaping does not stop a `javascript:` href.
|
|
18
|
+
*/
|
|
19
|
+
export function Refs({ items }: RefsProps): Element {
|
|
20
|
+
const rendered = items.map(({ title, url, note }, index) => (
|
|
21
|
+
<li id={`r${index + 1}`}>
|
|
22
|
+
<cite>{title}</cite>
|
|
23
|
+
{note === undefined ? null : `. ${note}`}
|
|
24
|
+
{url === undefined ? null : [' ', <a href={url}>{new URL(url).host}</a>]}
|
|
25
|
+
</li>
|
|
26
|
+
))
|
|
27
|
+
return <ol class="refs">{rendered}</ol>
|
|
28
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface LessonReferencesLinked {
|
|
2
|
+
readonly html: string
|
|
3
|
+
/** Four-digit numbers naming no lesson, each once, in first-seen order. */
|
|
4
|
+
readonly unresolved: readonly string[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A mention a learner would read as pointing at another lesson. The whitespace
|
|
9
|
+
* is captured rather than normalized, since a prettier-wrapped lesson can break
|
|
10
|
+
* a mention across a line.
|
|
11
|
+
*/
|
|
12
|
+
const MENTION = /\b(lesson|Lesson)(\s+)(\d{4})\b/g
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A link nav wrote on an earlier run, and the only kind it may rewrite. The
|
|
16
|
+
* close tag allows whitespace before its `>`, which is how prettier wraps it.
|
|
17
|
+
*/
|
|
18
|
+
const OWNED_LINK = /<a\s[^>]*\bdata-lesson-ref\b[^>]*>([\s\S]*?)<\/a\s*>/g
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A comment, a raw-text element taken whole so a `<` inside a script never
|
|
22
|
+
* reads as a tag, any other tag, or the text between tags.
|
|
23
|
+
*/
|
|
24
|
+
const TOKEN =
|
|
25
|
+
/<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>|<\/?[a-zA-Z][^>]*>|[^<]+|</g
|
|
26
|
+
|
|
27
|
+
const TAG_NAME = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Elements whose text stays plain. A link inside a quiz `label` navigates on
|
|
31
|
+
* the click meant to pick an option, and one inside `summary` fights the
|
|
32
|
+
* toggle.
|
|
33
|
+
*/
|
|
34
|
+
const SKIPPED = new Set(['a', 'code', 'pre', 'label', 'summary'])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Links every "lesson NNNN" mention in a lesson's authored region to the
|
|
38
|
+
* sibling file carrying that number, and reports the numbers naming no lesson.
|
|
39
|
+
* A link carrying `data-lesson-ref` is nav's own, so it is unwrapped and
|
|
40
|
+
* resolved again from its text, which re-points it after a slug rename and
|
|
41
|
+
* drops it once its target is gone. An author's own `<a>` is never touched.
|
|
42
|
+
*/
|
|
43
|
+
export function linkLessonReferences(
|
|
44
|
+
html: string,
|
|
45
|
+
targets: ReadonlyMap<string, string>,
|
|
46
|
+
current: string,
|
|
47
|
+
): LessonReferencesLinked {
|
|
48
|
+
const unresolved: string[] = []
|
|
49
|
+
let depth = 0
|
|
50
|
+
|
|
51
|
+
const linkText = (text: string): string =>
|
|
52
|
+
text.replace(MENTION, (mention, word, space, number: string) => {
|
|
53
|
+
const target = targets.get(number)
|
|
54
|
+
if (target === current) return mention
|
|
55
|
+
if (target === undefined) {
|
|
56
|
+
if (!unresolved.includes(number)) unresolved.push(number)
|
|
57
|
+
return mention
|
|
58
|
+
}
|
|
59
|
+
return `<a href="${target}" data-lesson-ref>${word}${space}${number}</a>`
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const unwrapped = html.replace(OWNED_LINK, (_link, text: string) => text)
|
|
63
|
+
|
|
64
|
+
const rewritten = unwrapped.replace(TOKEN, (token) => {
|
|
65
|
+
if (!token.startsWith('<') || token === '<') {
|
|
66
|
+
return depth === 0 ? linkText(token) : token
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tag = TAG_NAME.exec(token)
|
|
70
|
+
if (tag === null || !SKIPPED.has(tag[2].toLowerCase())) return token
|
|
71
|
+
|
|
72
|
+
if (tag[1] === '/') depth = Math.max(0, depth - 1)
|
|
73
|
+
else if (!token.endsWith('/>')) depth += 1
|
|
74
|
+
return token
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return { html: rewritten, unresolved }
|
|
78
|
+
}
|
package/src/teach/lesson.ts
CHANGED
package/src/teach/nav.ts
CHANGED
|
@@ -10,7 +10,9 @@ import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
|
|
|
10
10
|
import { stripFrontmatter } from '@/frontmatter'
|
|
11
11
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
12
12
|
import { PROJECT_ROOT } from '@/project-root'
|
|
13
|
+
import { linkLessonReferences } from '@/teach/cross-references'
|
|
13
14
|
import { TEACH_FONT_FACES } from '@/teach/fonts'
|
|
15
|
+
import { LESSON_NUMBER } from '@/teach/lesson'
|
|
14
16
|
import {
|
|
15
17
|
listWorkspaces,
|
|
16
18
|
readWorkspace,
|
|
@@ -1306,6 +1308,7 @@ function spliceRegion(
|
|
|
1306
1308
|
interface LessonRewritten {
|
|
1307
1309
|
readonly ok: true
|
|
1308
1310
|
readonly file: string
|
|
1311
|
+
readonly unresolved: readonly string[]
|
|
1309
1312
|
}
|
|
1310
1313
|
|
|
1311
1314
|
interface LessonRefused {
|
|
@@ -1334,15 +1337,57 @@ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
|
|
|
1334
1337
|
* alone, which keeps a second run byte-identical.
|
|
1335
1338
|
*/
|
|
1336
1339
|
function wrapMain(html: string): string {
|
|
1340
|
+
const bounds = authoredBounds(html)
|
|
1341
|
+
if (bounds === undefined) return html
|
|
1342
|
+
|
|
1343
|
+
const body = html.slice(bounds.start, bounds.end)
|
|
1344
|
+
if (/<main[\s>]/.test(body)) return html
|
|
1345
|
+
|
|
1346
|
+
return `${html.slice(0, bounds.start)}\n<main>\n${body.trim()}\n</main>\n${html.slice(bounds.end)}`
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function authoredBounds(
|
|
1350
|
+
html: string,
|
|
1351
|
+
): { readonly start: number; readonly end: number } | undefined {
|
|
1337
1352
|
const start = html.indexOf(HEADER_CLOSE)
|
|
1338
1353
|
const end = html.indexOf(FOOTNAV_OPEN, start)
|
|
1339
|
-
if (start === -1 || end === -1) return
|
|
1354
|
+
if (start === -1 || end === -1) return undefined
|
|
1355
|
+
return { start: start + HEADER_CLOSE.length, end }
|
|
1356
|
+
}
|
|
1340
1357
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1358
|
+
/** Each lesson number to the file carrying it, the first in filename order. */
|
|
1359
|
+
function lessonTargets(
|
|
1360
|
+
lessonFiles: readonly string[],
|
|
1361
|
+
): ReadonlyMap<string, string> {
|
|
1362
|
+
const targets = new Map<string, string>()
|
|
1363
|
+
for (const file of [...lessonFiles].sort()) {
|
|
1364
|
+
const number = LESSON_NUMBER.exec(file)?.[1]
|
|
1365
|
+
if (number !== undefined && !targets.has(number)) targets.set(number, file)
|
|
1366
|
+
}
|
|
1367
|
+
return targets
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Links the "lesson NNNN" mentions in the authored region, the one rewrite nav
|
|
1372
|
+
* makes between the header and footnav markers besides the `<main>` wrap.
|
|
1373
|
+
*/
|
|
1374
|
+
function linkAuthoredRegion(
|
|
1375
|
+
html: string,
|
|
1376
|
+
targets: ReadonlyMap<string, string>,
|
|
1377
|
+
file: string,
|
|
1378
|
+
): { readonly html: string; readonly unresolved: readonly string[] } {
|
|
1379
|
+
const bounds = authoredBounds(html)
|
|
1380
|
+
if (bounds === undefined) return { html, unresolved: [] }
|
|
1344
1381
|
|
|
1345
|
-
|
|
1382
|
+
const linked = linkLessonReferences(
|
|
1383
|
+
html.slice(bounds.start, bounds.end),
|
|
1384
|
+
targets,
|
|
1385
|
+
file,
|
|
1386
|
+
)
|
|
1387
|
+
return {
|
|
1388
|
+
html: `${html.slice(0, bounds.start)}${linked.html}${html.slice(bounds.end)}`,
|
|
1389
|
+
unresolved: linked.unresolved,
|
|
1390
|
+
}
|
|
1346
1391
|
}
|
|
1347
1392
|
|
|
1348
1393
|
/**
|
|
@@ -1359,6 +1404,7 @@ async function rewriteLesson(
|
|
|
1359
1404
|
metas: readonly LessonMeta[],
|
|
1360
1405
|
workspaces: readonly WorkspaceSummary[],
|
|
1361
1406
|
css: string,
|
|
1407
|
+
targets: ReadonlyMap<string, string>,
|
|
1362
1408
|
): Promise<LessonRewritten | LessonRefused> {
|
|
1363
1409
|
const file = metas[index].file
|
|
1364
1410
|
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
@@ -1439,8 +1485,9 @@ async function rewriteLesson(
|
|
|
1439
1485
|
html = spliced
|
|
1440
1486
|
}
|
|
1441
1487
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1488
|
+
const linked = linkAuthoredRegion(wrapMain(html), targets, file)
|
|
1489
|
+
await writeFile(path, linked.html)
|
|
1490
|
+
return { ok: true, file, unresolved: linked.unresolved }
|
|
1444
1491
|
}
|
|
1445
1492
|
|
|
1446
1493
|
export interface LessonSkipped {
|
|
@@ -1461,6 +1508,17 @@ export interface NavGenerated {
|
|
|
1461
1508
|
* reference page left unrendered on a Bun carrying no markdown renderer.
|
|
1462
1509
|
*/
|
|
1463
1510
|
readonly skipped: readonly LessonSkipped[]
|
|
1511
|
+
/**
|
|
1512
|
+
* A "lesson NNNN" mention naming no lesson in its workspace, one per distinct
|
|
1513
|
+
* number per lesson. The lesson still rewrote, so it counts in `lessons`.
|
|
1514
|
+
*/
|
|
1515
|
+
readonly unresolved: readonly LessonReferenceUnresolved[]
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
export interface LessonReferenceUnresolved {
|
|
1519
|
+
/** Relative to the root, the way a `skipped` entry is. */
|
|
1520
|
+
readonly file: string
|
|
1521
|
+
readonly lesson: string
|
|
1464
1522
|
}
|
|
1465
1523
|
|
|
1466
1524
|
const MARKDOWN_RENDERER = 'Bun.markdown'
|
|
@@ -1512,6 +1570,7 @@ export async function generateNav(
|
|
|
1512
1570
|
|
|
1513
1571
|
const contents: string[] = []
|
|
1514
1572
|
const skipped: LessonSkipped[] = []
|
|
1573
|
+
const unresolved: LessonReferenceUnresolved[] = []
|
|
1515
1574
|
let lessons = 0
|
|
1516
1575
|
let reference = 0
|
|
1517
1576
|
const canRenderMarkdown = hasMarkdownRenderer()
|
|
@@ -1555,6 +1614,7 @@ export async function generateNav(
|
|
|
1555
1614
|
await removeOrphanReferencePages(root, detail)
|
|
1556
1615
|
|
|
1557
1616
|
const css = stripImports(await readFile(cssPath, 'utf8'))
|
|
1617
|
+
const targets = lessonTargets(detail.lessonFiles)
|
|
1558
1618
|
|
|
1559
1619
|
for (let index = 0; index < metas.length; index += 1) {
|
|
1560
1620
|
const outcome = await rewriteLesson(
|
|
@@ -1564,10 +1624,17 @@ export async function generateNav(
|
|
|
1564
1624
|
metas,
|
|
1565
1625
|
listed.workspaces,
|
|
1566
1626
|
css,
|
|
1627
|
+
targets,
|
|
1567
1628
|
)
|
|
1568
1629
|
|
|
1569
1630
|
if (outcome.ok) {
|
|
1570
1631
|
lessons += 1
|
|
1632
|
+
for (const lesson of outcome.unresolved) {
|
|
1633
|
+
unresolved.push({
|
|
1634
|
+
file: join(detail.path, TEACH_LESSONS, outcome.file),
|
|
1635
|
+
lesson,
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1571
1638
|
} else {
|
|
1572
1639
|
skipped.push({
|
|
1573
1640
|
file: join(detail.path, TEACH_LESSONS, outcome.file),
|
|
@@ -1584,5 +1651,6 @@ export async function generateNav(
|
|
|
1584
1651
|
lessons,
|
|
1585
1652
|
reference,
|
|
1586
1653
|
skipped,
|
|
1654
|
+
unresolved,
|
|
1587
1655
|
}
|
|
1588
1656
|
}
|
package/src/teach/render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Heading } from '@/teach/components/heading'
|
|
2
2
|
import { List } from '@/teach/components/list'
|
|
3
3
|
import { Paragraph } from '@/teach/components/paragraph'
|
|
4
|
+
import { type Reference, Refs } from '@/teach/components/refs'
|
|
4
5
|
import { render } from '@/teach/html/jsx-runtime'
|
|
5
6
|
import type { TeachRefused } from '@/teach/workspace'
|
|
6
7
|
|
|
@@ -10,12 +11,14 @@ export type LessonBlock =
|
|
|
10
11
|
readonly type: 'paragraph'
|
|
11
12
|
readonly text: string
|
|
12
13
|
readonly lede?: boolean
|
|
14
|
+
readonly cites?: readonly number[]
|
|
13
15
|
}
|
|
14
16
|
| {
|
|
15
17
|
readonly type: 'list'
|
|
16
18
|
readonly items: readonly string[]
|
|
17
19
|
readonly ordered?: boolean
|
|
18
20
|
}
|
|
21
|
+
| { readonly type: 'refs'; readonly items: readonly Reference[] }
|
|
19
22
|
| { readonly type: 'raw'; readonly html: string }
|
|
20
23
|
|
|
21
24
|
export interface RenderRendered {
|
|
@@ -25,7 +28,9 @@ export interface RenderRendered {
|
|
|
25
28
|
|
|
26
29
|
export type RenderOutcome = RenderRendered | TeachRefused
|
|
27
30
|
|
|
28
|
-
const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
|
|
31
|
+
const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'refs', 'raw'] as const
|
|
32
|
+
|
|
33
|
+
const LINK_PROTOCOLS = new Set(['http:', 'https:'])
|
|
29
34
|
|
|
30
35
|
function isBlockType(value: unknown): value is LessonBlock['type'] {
|
|
31
36
|
return (BLOCK_TYPES as readonly string[]).includes(value as string)
|
|
@@ -48,10 +53,46 @@ function isStringArray(value: unknown): value is readonly string[] {
|
|
|
48
53
|
return Array.isArray(value) && value.every(isString)
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function isCiteArray(value: unknown): value is readonly number[] {
|
|
57
|
+
return (
|
|
58
|
+
Array.isArray(value) &&
|
|
59
|
+
value.length > 0 &&
|
|
60
|
+
value.every((cite) => Number.isInteger(cite) && cite > 0)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isLinkUrl(value: string): boolean {
|
|
65
|
+
if (!URL.canParse(value)) return false
|
|
66
|
+
return LINK_PROTOCOLS.has(new URL(value).protocol)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readReference(item: unknown, position: number): Reference | string {
|
|
70
|
+
if (typeof item !== 'object' || item === null) {
|
|
71
|
+
return `reference ${position} is not an object`
|
|
72
|
+
}
|
|
73
|
+
const { title, url, note } = item as Record<string, unknown>
|
|
74
|
+
if (!isString(title) || title === '') {
|
|
75
|
+
return `reference ${position} needs a non-empty string title`
|
|
76
|
+
}
|
|
77
|
+
if (note !== undefined && !isString(note)) {
|
|
78
|
+
return `reference ${position} note must be a string`
|
|
79
|
+
}
|
|
80
|
+
if (url !== undefined && !(isString(url) && isLinkUrl(url))) {
|
|
81
|
+
return `reference ${position} url must be an http or https URL`
|
|
82
|
+
}
|
|
83
|
+
return { title, url, note }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface RenderedBlock {
|
|
87
|
+
readonly html: string
|
|
88
|
+
readonly cites?: readonly number[]
|
|
89
|
+
readonly referenceCount?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
function renderBlock(
|
|
52
93
|
block: unknown,
|
|
53
94
|
index: number,
|
|
54
|
-
):
|
|
95
|
+
): RenderedBlock | TeachRefused {
|
|
55
96
|
if (typeof block !== 'object' || block === null || !('type' in block)) {
|
|
56
97
|
return badBlock(index, 'not an object carrying a type')
|
|
57
98
|
}
|
|
@@ -83,8 +124,24 @@ function renderBlock(
|
|
|
83
124
|
if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
|
|
84
125
|
return badBlock(index, 'paragraph lede must be a boolean')
|
|
85
126
|
}
|
|
127
|
+
if (fields.cites !== undefined && !isCiteArray(fields.cites)) {
|
|
128
|
+
return badBlock(
|
|
129
|
+
index,
|
|
130
|
+
'paragraph cites must be a non-empty array of positive integers',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (fields.lede === true && fields.cites !== undefined) {
|
|
134
|
+
return badBlock(index, 'a lede paragraph cannot carry cites')
|
|
135
|
+
}
|
|
86
136
|
return {
|
|
87
|
-
html: render(
|
|
137
|
+
html: render(
|
|
138
|
+
Paragraph({
|
|
139
|
+
lede: fields.lede,
|
|
140
|
+
cites: fields.cites,
|
|
141
|
+
children: fields.text,
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
cites: fields.cites,
|
|
88
145
|
}
|
|
89
146
|
}
|
|
90
147
|
case 'list': {
|
|
@@ -98,6 +155,21 @@ function renderBlock(
|
|
|
98
155
|
html: render(List({ ordered: fields.ordered, items: fields.items })),
|
|
99
156
|
}
|
|
100
157
|
}
|
|
158
|
+
case 'refs': {
|
|
159
|
+
if (!Array.isArray(fields.items) || fields.items.length === 0) {
|
|
160
|
+
return badBlock(index, 'refs needs a non-empty items array')
|
|
161
|
+
}
|
|
162
|
+
const items: Reference[] = []
|
|
163
|
+
for (const [position, item] of fields.items.entries()) {
|
|
164
|
+
const reference = readReference(item, position + 1)
|
|
165
|
+
if (isString(reference)) return badBlock(index, reference)
|
|
166
|
+
items.push(reference)
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
html: render(Refs({ items })),
|
|
170
|
+
referenceCount: items.length,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
101
173
|
case 'raw': {
|
|
102
174
|
if (!isString(fields.html)) {
|
|
103
175
|
return badBlock(index, 'raw needs a string html')
|
|
@@ -112,15 +184,31 @@ function renderBlock(
|
|
|
112
184
|
* `lesson.test.tsx` composes as JSX, so a real lesson and the component test
|
|
113
185
|
* share one rendering mechanism. Every block past the first refusal
|
|
114
186
|
* goes unread, matching how a malformed JSON parse refuses the whole call.
|
|
187
|
+
* Citations resolve across blocks once every block has rendered, so a marker
|
|
188
|
+
* with no reference refuses rather than leaving a dead anchor.
|
|
115
189
|
*/
|
|
116
190
|
export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
|
|
117
|
-
const rendered:
|
|
191
|
+
const rendered: RenderedBlock[] = []
|
|
192
|
+
let referenceCount = 0
|
|
118
193
|
|
|
119
194
|
for (const [index, block] of blocks.entries()) {
|
|
120
195
|
const result = renderBlock(block, index)
|
|
121
196
|
if ('ok' in result) return result
|
|
122
|
-
|
|
197
|
+
if (result.referenceCount !== undefined) {
|
|
198
|
+
if (referenceCount > 0) {
|
|
199
|
+
return badBlock(index, 'a lesson carries one refs block')
|
|
200
|
+
}
|
|
201
|
+
referenceCount = result.referenceCount
|
|
202
|
+
}
|
|
203
|
+
rendered.push(result)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const [index, block] of rendered.entries()) {
|
|
207
|
+
const dangling = block.cites?.find((cite) => cite > referenceCount)
|
|
208
|
+
if (dangling !== undefined) {
|
|
209
|
+
return badBlock(index, `cite ${dangling} resolves to no reference`)
|
|
210
|
+
}
|
|
123
211
|
}
|
|
124
212
|
|
|
125
|
-
return { ok: true, html: rendered.join('\n') }
|
|
213
|
+
return { ok: true, html: rendered.map((block) => block.html).join('\n') }
|
|
126
214
|
}
|
package/src/tooling/inject.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { copyPreservingMode } from '@/copy'
|
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
|
|
7
7
|
import { resolveSurfacePath } from '@/surface-root'
|
|
8
8
|
import { mergeSections, pruneSections } from '@/tooling/gitignore'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ancestorsFirst,
|
|
11
|
+
configPaths,
|
|
12
|
+
listFiles,
|
|
13
|
+
type Manifest,
|
|
14
|
+
} from '@/tooling/manifest'
|
|
10
15
|
import {
|
|
11
16
|
applyScripts,
|
|
12
17
|
collectDeps,
|
|
@@ -97,9 +102,14 @@ export async function injectSeeds(
|
|
|
97
102
|
target: string,
|
|
98
103
|
): Promise<string[]> {
|
|
99
104
|
const applied: string[] = []
|
|
105
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
106
|
+
const shadowed = configPaths(chain)
|
|
100
107
|
|
|
101
108
|
for (const manifest of ancestorsFirst(chain)) {
|
|
102
|
-
const files = listFiles(manifest.seedsDir)
|
|
109
|
+
const files = listFiles(manifest.seedsDir).filter(
|
|
110
|
+
(rel) =>
|
|
111
|
+
!shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
112
|
+
)
|
|
103
113
|
if (files.length === 0) continue
|
|
104
114
|
|
|
105
115
|
logStep(`Applying ${manifest.name} seeds`)
|
|
@@ -111,9 +121,7 @@ export async function injectSeeds(
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
const spellConfig =
|
|
115
|
-
? subfolderSpellConfig(chain)
|
|
116
|
-
: undefined
|
|
124
|
+
const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
|
|
117
125
|
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
126
|
logStep('Applying subfolder spell config')
|
|
119
127
|
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -44,6 +44,15 @@ export function listFiles(dir: string): string[] {
|
|
|
44
44
|
].sort()
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every path any stack in the chain ships as a config. A seed at one of these
|
|
49
|
+
* paths is shadowed, since the config overwrites it on every sync. `scan` and
|
|
50
|
+
* `injectSeeds` both read this, so the report and the write cannot disagree.
|
|
51
|
+
*/
|
|
52
|
+
export function configPaths(chain: readonly Manifest[]): Set<string> {
|
|
53
|
+
return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Reverses a chain so the furthest ancestor is applied first and nearer
|
|
49
58
|
* stacks overwrite it.
|
package/src/tooling/scan.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { resolveSurfacePath } from '@/surface-root'
|
|
4
4
|
import { mergeSections } from '@/tooling/gitignore'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ancestorsFirst,
|
|
7
|
+
configPaths,
|
|
8
|
+
listFiles,
|
|
9
|
+
type Manifest,
|
|
10
|
+
} from '@/tooling/manifest'
|
|
6
11
|
import {
|
|
7
12
|
collectDeps,
|
|
8
13
|
collectScripts,
|
|
@@ -63,10 +68,13 @@ function isIdentical(a: string, b: string): boolean {
|
|
|
63
68
|
* stack first, while dependencies and gitignore entries resolve from the
|
|
64
69
|
* furthest ancestor inward.
|
|
65
70
|
*
|
|
66
|
-
* A
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
71
|
+
* A seed at a path the chain also ships as a config is left to the config,
|
|
72
|
+
* so the path is reported once.
|
|
73
|
+
*
|
|
74
|
+
* A target below its git toplevel is scanned as a subfolder: a config or seed
|
|
75
|
+
* GitHub reads only at the root moves to `withheld` and counts as no change,
|
|
76
|
+
* and the nested spell config joins the seeds. The scope is read here so every
|
|
77
|
+
* caller of `scan` agrees without passing a flag.
|
|
70
78
|
*/
|
|
71
79
|
export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
72
80
|
const isSubfolder = isSubfolderTarget(target)
|
|
@@ -98,13 +106,22 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
const seeds: SeedState[] = []
|
|
101
|
-
const seenSeeds =
|
|
109
|
+
const seenSeeds = configPaths(chain)
|
|
102
110
|
|
|
103
111
|
for (const manifest of chain) {
|
|
104
112
|
for (const rel of listFiles(manifest.seedsDir)) {
|
|
105
113
|
if (seenSeeds.has(rel)) continue
|
|
106
114
|
seenSeeds.add(rel)
|
|
107
115
|
|
|
116
|
+
if (isSubfolder && isWithheldInSubfolder(rel)) {
|
|
117
|
+
withheld.push({
|
|
118
|
+
rel,
|
|
119
|
+
stack: manifest.name,
|
|
120
|
+
present: existsSync(join(target, rel)),
|
|
121
|
+
})
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
seeds.push({
|
|
109
126
|
rel,
|
|
110
127
|
state: existsSync(resolveSurfacePath(target, rel))
|
package/src/tooling/subfolder.ts
CHANGED
|
@@ -50,9 +50,9 @@ export function subfolderPath(target: string): string | undefined {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* GitHub reads workflows only at the repository root, so a `.github/` copied
|
|
53
|
-
* into a subfolder is a file nothing runs. `scan
|
|
54
|
-
* read this, so the set the diff withholds and the set the
|
|
55
|
-
* disagree.
|
|
53
|
+
* into a subfolder is a file nothing runs. `scan`, `injectConfigs`, and
|
|
54
|
+
* `injectSeeds` all read this, so the set the diff withholds and the set the
|
|
55
|
+
* sync skips cannot disagree, whether the path ships as a config or a seed.
|
|
56
56
|
*/
|
|
57
57
|
export function isWithheldInSubfolder(rel: string): boolean {
|
|
58
58
|
return rel.startsWith(WITHHELD_PREFIX)
|
package/standards/context.md
CHANGED
|
@@ -105,8 +105,8 @@ Only the `development` entry carries this section. It is not a general-purpose h
|
|
|
105
105
|
|
|
106
106
|
## Length
|
|
107
107
|
|
|
108
|
-
- Aim for one entry per domain.
|
|
109
|
-
- Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number. Rendered lines count as `markdown.md` defines them.
|
|
108
|
+
- Aim for one entry per domain. Length is a symptom, not the defect, and the one hard cap is the whole-document ceiling `markdown.md` states.
|
|
109
|
+
- Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number. The 150 is a diagnostic checkpoint and never gates. Rendered lines count as `markdown.md` defines them.
|
|
110
110
|
- Never cut a `## Decisions` or `## Gotchas` entry to shorten a file. Cut a `## Layout` or `## CLI` section instead.
|
|
111
111
|
- Retire a decision or gotcha once its subject is gone, rewriting the bullet to state the current design rather than leaving the narration of what it replaced beside it. A rejected alternative is not a retired one, so what was tried and why it lost stays whatever its age. The rule above protects content whose subject is live, and this one releases content whose subject is not.
|
|
112
112
|
- Rewrite a decision a later one replaced rather than appending the replacement beside it. The subject is still live, so the rule above does not reach it, and two bullets on one subject leave a reader to work out which of them is current. State the design that stands and keep the superseded reasoning only where it is the alternative that lost.
|
package/standards/markdown.md
CHANGED
|
@@ -32,6 +32,8 @@ Does not govern:
|
|
|
32
32
|
- Proper nouns and product names retain their casing in headings
|
|
33
33
|
- Past roughly 40 rendered lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint, not a cap.
|
|
34
34
|
- Count rendered lines rather than source lines, wrapping each source line at 80 columns and summing the heights. Source lines undercount a file authored one line per bullet, where a block of fifteen paragraph-bullets occupies fifteen lines and renders past sixty. A checkpoint another standard states counts the same unit, so a file measured one way never sits beside a run measured another.
|
|
35
|
+
- Keep a whole document at or under 300 rendered lines, counted over the full source with frontmatter and fenced blocks included, since a session pays for every line it loads. This one is a ceiling rather than a checkpoint. `canon markdown audit` lists every document past it without failing the run, and a gate stage reads the same number, so whether a push fails on it is the project's gate to decide. Split the document at a seam rather than compressing it.
|
|
36
|
+
- Exempt a file named `CHANGELOG.md`, which a release tool rewrites, and a document carrying `<!-- canon-length-exempt: <reason> -->` on a line of its own outside a fenced block. A marker naming no reason exempts nothing.
|
|
35
37
|
- Exempt a block whose lines are all list items at one level averaging under roughly 130 characters. A flat list of short peers is already navigable, and a subheading dropped into it splits a set that belongs together. Bullet count says nothing on its own, since a catalog of one-liners and a stack of paragraphs reach the same count and read nothing alike, so weight is what decides.
|
|
36
38
|
- Mixing prose with that list, or nesting levels inside it, ends the exemption at any weight.
|
|
37
39
|
- Exempt a block whose lines are all table rows, at any length. The peer list above is exempt because it is already navigable, and a table because the remedy does not exist: a subheading dropped inside one splits the table rather than the run, so no edit short of rewriting it as a list clears the checkpoint.
|
package/standards/skill.md
CHANGED
|
@@ -180,6 +180,7 @@ Without this skill, a session <observed failure>, <observed failure>.
|
|
|
180
180
|
- Contain only behavioral rules (what to do, what not to do) and pointers to reference docs. Narrative descriptions of what files are or how the system works belong in `docs/`, not in the skill body.
|
|
181
181
|
- State rules, not inventories. Reference docs for lists that change, and phrase a rule as a ban on the forbidden shape rather than an enumeration of allowed options, so it stays stable as categories change.
|
|
182
182
|
- Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
|
|
183
|
+
- State the current rule only, in the body and in every file under `references/`. The incident and the date that earned a rule go in `REQUIREMENT.md` under `Gap`, or stay in git. A date inside a fence or a code span is example data and stays. `canon claude skills audit` names every other ISO date.
|
|
183
184
|
- Group bullets under H2 headings by domain concern. Keep dos and don'ts together under the topic they belong to rather than splitting them into flat rules and constraints sections.
|
|
184
185
|
- One actionable constraint per bullet. Prefer the `X over Y` form for preferences.
|
|
185
186
|
- Do not include code examples unless a one to three line inline snippet captures a pattern the model cannot infer.
|
|
@@ -15,8 +15,6 @@ Golden config files live in `tooling/base/configs/` and are copied into the targ
|
|
|
15
15
|
- `.editorconfig`: `root = true`, with an `[*.sh]` block setting `indent_style = space` and `indent_size = 2`.
|
|
16
16
|
- `commitlint.config.js`: ESM default export extending `@commitlint/config-conventional`. Rules are `header-max-length: 72`, `scope-case: lower-case`, `subject-full-stop: never`, and `subject-case` disabled.
|
|
17
17
|
- `.husky/`: `pre-commit`, `commit-msg`, `pre-push`, `post-merge`, `post-rewrite`.
|
|
18
|
-
- `.github/workflows/verify.yml`: runs on pull requests targeting `main` and on `workflow_dispatch`.
|
|
19
|
-
- `.github/pull_request_template.md`: `## Summary`, `## Key Changes`, `## Technical Context`, `## Testing`.
|
|
20
18
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for Prettier, cspell, shfmt, and shellcheck.
|
|
21
19
|
- `scripts/verify.sh`: the maintenance entry point behind `check`.
|
|
22
20
|
|
|
@@ -28,6 +26,8 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
|
|
|
28
26
|
- `.cspell/project-terms.txt` and `.cspell/tech-stack.txt`: one word per line, sorted alphabetically.
|
|
29
27
|
- `.lintstagedrc`: the glob map below.
|
|
30
28
|
- `.prettierignore`: created empty. Projects add their own entries.
|
|
29
|
+
- `.github/workflows/verify.yml`: runs on pull requests targeting `main` and on `workflow_dispatch`. A project with more than one language root adds its own jobs here.
|
|
30
|
+
- `.github/pull_request_template.md`: `## Summary`, `## Key Changes`, `## Technical Context`, `## Testing`. A project adds the checklist lines its own review needs.
|
|
31
31
|
- `canon/context/`: extend the `ci` and `development` entries with project-specific commands, workflows, or deploy steps. Canonical rationale stays in this reference.
|
|
32
32
|
|
|
33
33
|
## Tool pairing
|