@erclx/canon 5.38.0 → 5.39.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/api-design/SKILL.md +12 -9
- package/claude/skills/auto-ship/SKILL.md +43 -23
- package/claude/skills/backlog-triage/SKILL.md +62 -53
- package/claude/skills/backlog-triage/references/apply.md +69 -0
- package/claude/skills/build-in-slices/SKILL.md +8 -4
- package/claude/skills/canon-cli/SKILL.md +5 -1
- package/claude/skills/canon-feedback/SKILL.md +18 -8
- package/claude/skills/canon-feedback-triage/SKILL.md +10 -3
- package/claude/skills/canvas-board/SKILL.md +36 -17
- package/claude/skills/ci-workflow/SKILL.md +5 -4
- package/claude/skills/code-craft/SKILL.md +10 -6
- package/claude/skills/codebase-layout/SKILL.md +3 -5
- package/claude/skills/context-fold/REQUIREMENT.md +4 -4
- package/claude/skills/context-fold/SKILL.md +42 -23
- package/claude/skills/context-fold/references/context-refresh.md +4 -3
- package/claude/skills/context-fold/references/context.md +79 -64
- package/claude/skills/create-rule/SKILL.md +7 -3
- package/claude/skills/create-skill/SKILL.md +27 -7
- package/claude/skills/create-skill/references/skill-reference.md +41 -17
- package/claude/skills/decision-escalate/SKILL.md +8 -5
- package/claude/skills/deploy-app/SKILL.md +1 -1
- package/claude/skills/deprecation-migration/SKILL.md +12 -4
- package/claude/skills/design-taste/SKILL.md +17 -11
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -0
- package/claude/skills/docs-sync/SKILL.md +17 -6
- package/claude/skills/docs-sync/references/docs.md +54 -21
- package/claude/skills/docs-sync/references/readme.md +32 -8
- package/claude/skills/document-health/SKILL.md +3 -4
- package/claude/skills/draft-and-pick/SKILL.md +73 -47
- package/claude/skills/draft-doc/SKILL.md +9 -4
- package/claude/skills/draft-doc/references/context.md +4 -4
- package/claude/skills/draft-doc/references/docs.md +1 -0
- package/claude/skills/draft-doc/references/wiki.md +3 -2
- package/claude/skills/draft-figure/SKILL.md +9 -7
- package/claude/skills/draft-identity/SKILL.md +24 -52
- package/claude/skills/draft-identity/references/card-route.md +46 -0
- package/claude/skills/draft-ready/SKILL.md +19 -7
- package/claude/skills/draft-screencast/SKILL.md +6 -112
- package/claude/skills/draft-screencast/references/beats-template.md +119 -0
- package/claude/skills/draft-slides/SKILL.md +7 -4
- package/claude/skills/git-branch/SKILL.md +3 -3
- package/claude/skills/git-commit/SKILL.md +5 -3
- package/claude/skills/git-followup/SKILL.md +16 -9
- package/claude/skills/git-issue/SKILL.md +6 -5
- package/claude/skills/git-pr/SKILL.md +56 -44
- package/claude/skills/git-ship/SKILL.md +43 -34
- package/claude/skills/git-ship/references/ci-watch.md +31 -0
- package/claude/skills/git-split/SKILL.md +28 -100
- package/claude/skills/git-split/references/independent.md +40 -0
- package/claude/skills/git-split/references/stacked.md +73 -0
- package/claude/skills/git-stage/SKILL.md +9 -7
- package/claude/skills/git-worktree/SKILL.md +27 -49
- package/claude/skills/git-worktree/references/cleanup.md +55 -0
- package/claude/skills/markdown-craft/SKILL.md +2 -2
- package/claude/skills/markdown-craft/references/markdown.md +82 -53
- package/claude/skills/plan-feature/SKILL.md +52 -46
- package/claude/skills/plan-feature/references/batches.md +46 -0
- package/claude/skills/plan-groundwork/SKILL.md +60 -74
- package/claude/skills/plan-groundwork/references/output.md +48 -0
- package/claude/skills/plan-intake/SKILL.md +28 -21
- package/claude/skills/plan-intake-answer/SKILL.md +14 -13
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +31 -11
- package/claude/skills/review-address/SKILL.md +76 -158
- package/claude/skills/review-address/references/rebase-only.md +34 -0
- package/claude/skills/review-craft/SKILL.md +27 -11
- package/claude/skills/review-pr/SKILL.md +91 -40
- package/claude/skills/review-ui/SKILL.md +62 -37
- package/claude/skills/review-ui/references/hosted-preview.md +30 -0
- package/claude/skills/role-orchestrator/SKILL.md +105 -74
- package/claude/skills/role-orchestrator/references/orchestrator-filing.md +26 -0
- package/claude/skills/role-planner/SKILL.md +66 -64
- package/claude/skills/role-reviewer/SKILL.md +57 -46
- package/claude/skills/role-worker/SKILL.md +68 -63
- package/claude/skills/search-craft/SKILL.md +7 -5
- package/claude/skills/session-compact/SKILL.md +16 -11
- package/claude/skills/session-map/SKILL.md +31 -10
- package/claude/skills/session-relay/SKILL.md +32 -65
- package/claude/skills/session-resume/SKILL.md +18 -13
- package/claude/skills/session-worktree/SKILL.md +70 -42
- package/claude/skills/systematic-debugging/SKILL.md +6 -1
- package/claude/skills/target-check/SKILL.md +13 -6
- package/claude/skills/target-setup/SKILL.md +39 -27
- package/claude/skills/task-board/SKILL.md +68 -60
- package/claude/skills/task-board/references/create-report.md +36 -0
- package/claude/skills/task-complete/SKILL.md +20 -18
- package/claude/skills/teach-workspace/SKILL.md +61 -33
- package/claude/skills/test-craft/SKILL.md +2 -1
- package/claude/skills/test-first/SKILL.md +15 -9
- package/claude/skills/ui-checklist/SKILL.md +18 -16
- package/claude/skills/ux-audit/SKILL.md +9 -10
- package/claude/skills/ux-measure/SKILL.md +22 -18
- package/claude/skills/video-craft/SKILL.md +15 -7
- package/claude/skills/write-human/SKILL.md +9 -1
- package/claude/skills/youtube-transcripts/SKILL.md +2 -1
- package/docs/agents/context-audit-checks.md +19 -1
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/driver.md +1 -1
- package/docs/agents/hooks.md +21 -1
- package/docs/agents/index.md +10 -14
- package/docs/agents/indexes.md +25 -29
- package/docs/agents/install-and-sync.md +124 -235
- package/docs/agents/markdown-audit-density.md +39 -0
- package/docs/agents/markdown-audit-weight.md +9 -7
- package/docs/agents/markdown-audit.md +18 -14
- package/docs/agents/migrate.md +41 -16
- package/docs/agents/output-shape.md +60 -45
- package/docs/agents/overview.md +36 -12
- package/docs/agents/routing.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/skills-reach.md +1 -1
- package/docs/agents/targets.md +25 -77
- package/docs/target/migrations.md +1 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +2 -2
- package/docs/workflow/parallel-features.md +2 -2
- package/docs/workflow/skill-map.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +1 -1
- package/src/commands/claude.ts +1 -1
- package/src/commands/context.ts +106 -7
- package/src/commands/hooks.ts +63 -0
- package/src/commands/markdown.ts +169 -32
- package/src/context/audit.ts +256 -5
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/gate.ts +23 -7
- package/src/context/shape-gated.ts +28 -0
- package/src/design/document.ts +1 -1
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +15 -190
- package/src/gate/stages.ts +7 -23
- package/src/hooks/reply-links.ts +110 -0
- package/src/markdown/density.ts +258 -0
- package/src/markdown/gate.ts +57 -18
- package/src/markdown/reference-shape.ts +103 -0
- package/src/markdown/scope.ts +174 -0
- package/src/markdown/structure.ts +9 -59
- package/src/shipped/references.ts +1 -1
- package/tooling/claude/seeds/.claude/hooks/reply-links.sh +72 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +22 -1
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/docs/agents/commands.md +0 -154
- package/docs/agents/docs.md +0 -27
- package/docs/agents/drift-surfaces.md +0 -201
- package/docs/agents/gov-detect.md +0 -47
- package/docs/agents/scripting.md +0 -142
- package/src/markdown/growth.ts +0 -43
package/src/gate/measures.ts
CHANGED
|
@@ -18,18 +18,9 @@ import {
|
|
|
18
18
|
import { familyKeys } from '@/claude/skills-families'
|
|
19
19
|
import { measureDesignProse } from '@/design/budget'
|
|
20
20
|
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
21
|
-
import {
|
|
22
|
-
listChangedFiles,
|
|
23
|
-
listFilesAtRef,
|
|
24
|
-
listRepositoryFiles,
|
|
25
|
-
readFileAtRef,
|
|
26
|
-
resolveBaseRef,
|
|
27
|
-
} from '@/git/files'
|
|
21
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
28
22
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
29
23
|
import { resolveMarkdown } from '@/markdown/files'
|
|
30
|
-
import { grownBlocks, normalizeProse } from '@/markdown/growth'
|
|
31
|
-
import { bodyLines } from '@/markdown/scan'
|
|
32
|
-
import { bulletStacks as findBulletStacks } from '@/markdown/stacks'
|
|
33
24
|
import { CHECKPOINTS } from '@/markdown/structure'
|
|
34
25
|
import {
|
|
35
26
|
isShippedCorpus,
|
|
@@ -510,182 +501,6 @@ export const documentCeiling: Measure = async (ctx) => {
|
|
|
510
501
|
}
|
|
511
502
|
}
|
|
512
503
|
|
|
513
|
-
/**
|
|
514
|
-
* The tracked documents a bullet run is held against: the context entries, the
|
|
515
|
-
* docs pages, and the skill bodies and references of both skill trees.
|
|
516
|
-
*
|
|
517
|
-
* Rules and the requirement records stay out, since a rule is one imperative
|
|
518
|
-
* per bullet and a requirement is a record of its own. Named here and nowhere
|
|
519
|
-
* else, so the stage and its tests read one scope.
|
|
520
|
-
*/
|
|
521
|
-
const STACK_SCOPE = [
|
|
522
|
-
/^canon\/context\/.+\.md$/,
|
|
523
|
-
/^docs\/.+\.md$/,
|
|
524
|
-
/^(?:claude|\.claude)\/skills\/[^/]+\/SKILL\.md$/,
|
|
525
|
-
/^(?:claude|\.claude)\/skills\/[^/]+\/references\/.+\.md$/,
|
|
526
|
-
]
|
|
527
|
-
|
|
528
|
-
/** Whether a path is one the bullet cap holds. */
|
|
529
|
-
export function inStackScope(rel: string): boolean {
|
|
530
|
-
return STACK_SCOPE.some((pattern) => pattern.test(rel))
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/** The context entries alone, where length drifts because `context-fold` appends after every merge. */
|
|
534
|
-
const GROWTH_SCOPE = /^canon\/context\/.+\.md$/
|
|
535
|
-
|
|
536
|
-
/** Whether a path is one the growth check reads. */
|
|
537
|
-
export function inGrowthScope(rel: string): boolean {
|
|
538
|
-
return GROWTH_SCOPE.test(rel)
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Every in-scope document against the five-bullet cap.
|
|
543
|
-
*
|
|
544
|
-
* The failure names each file with its longest run and where it opens. Read
|
|
545
|
-
* in-process rather than through `canon markdown audit`, whose exit code stays
|
|
546
|
-
* bans and dead links only, for the reason `documentCeiling` gives: the plan
|
|
547
|
-
* and groundwork skills run that audit on gitignored records, where a long
|
|
548
|
-
* list is not a defect.
|
|
549
|
-
*/
|
|
550
|
-
export const bulletStacks: Measure = async (ctx) => {
|
|
551
|
-
const files = await listRepositoryFiles(ctx.root)
|
|
552
|
-
if (files === undefined) {
|
|
553
|
-
return {
|
|
554
|
-
emissions: [],
|
|
555
|
-
unmeasured: 'git could not list the tree, so no bullet run was measured.',
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const scoped = files.filter(inStackScope)
|
|
560
|
-
const failing: { rel: string; count: number; line: number }[] = []
|
|
561
|
-
|
|
562
|
-
for (const rel of scoped) {
|
|
563
|
-
let source: string
|
|
564
|
-
try {
|
|
565
|
-
source = readFileSync(join(ctx.root, rel), 'utf8')
|
|
566
|
-
} catch {
|
|
567
|
-
continue
|
|
568
|
-
}
|
|
569
|
-
const runs = findBulletStacks(bodyLines(source))
|
|
570
|
-
if (runs.length === 0) continue
|
|
571
|
-
const longest = runs.reduce((a, b) => (b.count > a.count ? b : a))
|
|
572
|
-
failing.push({ rel, count: longest.count, line: longest.line })
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if (failing.length === 0) {
|
|
576
|
-
return {
|
|
577
|
-
emissions: [
|
|
578
|
-
info(
|
|
579
|
-
`No run of more than 5 plain bullets across ${plural(scoped.length, 'document')}`,
|
|
580
|
-
),
|
|
581
|
-
],
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const where = failing
|
|
586
|
-
.sort((a, b) => b.count - a.count)
|
|
587
|
-
.map((f) => `${f.rel}:${f.line} ${f.count} bullets`)
|
|
588
|
-
.join(', ')
|
|
589
|
-
return {
|
|
590
|
-
emissions: [],
|
|
591
|
-
failure: `${plural(failing.length, 'document')} carrying a run of more than 5 plain bullets: ${where}. Split each run under H3 headings with a few bullets or prose beneath, or open every item with a bold label or a link.`,
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* A context entry's paragraph or top-level bullet past its checkpoint that
|
|
597
|
-
* this branch added or changed.
|
|
598
|
-
*
|
|
599
|
-
* A block fails when its text is absent from every context entry the merge
|
|
600
|
-
* base held, so an unchanged long block passes wherever it now sits and an
|
|
601
|
-
* edited or new one fails. Reading the whole base folder rather than the same
|
|
602
|
-
* path is what lets a block move into a sibling entry. No resolvable base
|
|
603
|
-
* reports unmeasured rather than a pass, since a missing base would otherwise
|
|
604
|
-
* read every block as new or none as new.
|
|
605
|
-
*
|
|
606
|
-
* Only context entries are held to it. Length drifts there because
|
|
607
|
-
* `context-fold` appends after every merge, and in docs and skills it stays the
|
|
608
|
-
* audit's advisory warning.
|
|
609
|
-
*/
|
|
610
|
-
export const paragraphGrowth: Measure = async (ctx) => {
|
|
611
|
-
const base = await resolveBaseRef(ctx.root)
|
|
612
|
-
if (base === undefined) {
|
|
613
|
-
return {
|
|
614
|
-
emissions: [],
|
|
615
|
-
unmeasured:
|
|
616
|
-
'no merge base resolved, so no paragraph or bullet was compared against it.',
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const changed = await listChangedFiles(ctx.root, base)
|
|
621
|
-
if (changed === undefined) {
|
|
622
|
-
return {
|
|
623
|
-
emissions: [],
|
|
624
|
-
unmeasured:
|
|
625
|
-
'git could not list the branch range, so no block was compared.',
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const grown: {
|
|
630
|
-
rel: string
|
|
631
|
-
kind: string
|
|
632
|
-
line: number
|
|
633
|
-
characters: number
|
|
634
|
-
}[] = []
|
|
635
|
-
let corpus: string | undefined
|
|
636
|
-
|
|
637
|
-
for (const rel of changed.filter(inGrowthScope)) {
|
|
638
|
-
let source: string
|
|
639
|
-
try {
|
|
640
|
-
source = readFileSync(join(ctx.root, rel), 'utf8')
|
|
641
|
-
} catch {
|
|
642
|
-
continue
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const own = normalizeProse((await readFileAtRef(ctx.root, base, rel)) ?? '')
|
|
646
|
-
const first = grownBlocks(source, own)
|
|
647
|
-
if (first.length === 0) continue
|
|
648
|
-
|
|
649
|
-
if (corpus === undefined) {
|
|
650
|
-
const paths =
|
|
651
|
-
(await listFilesAtRef(ctx.root, base, 'canon/context')) ?? []
|
|
652
|
-
const texts = await Promise.all(
|
|
653
|
-
paths
|
|
654
|
-
.filter(inGrowthScope)
|
|
655
|
-
.map((path) => readFileAtRef(ctx.root, base, path)),
|
|
656
|
-
)
|
|
657
|
-
corpus = normalizeProse(texts.filter((t) => t !== undefined).join('\n'))
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
for (const block of grownBlocks(source, corpus)) {
|
|
661
|
-
grown.push({
|
|
662
|
-
rel,
|
|
663
|
-
kind: block.kind,
|
|
664
|
-
line: block.line,
|
|
665
|
-
characters: block.characters,
|
|
666
|
-
})
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
if (grown.length === 0) {
|
|
671
|
-
return {
|
|
672
|
-
emissions: [
|
|
673
|
-
info(
|
|
674
|
-
`No paragraph past ${CHECKPOINTS.paragraph} characters and no bullet past ${CHECKPOINTS.bullet} grew in a context entry`,
|
|
675
|
-
),
|
|
676
|
-
],
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const where = grown
|
|
681
|
-
.map((g) => `${g.rel}:${g.line} ${g.kind} ${g.characters} characters`)
|
|
682
|
-
.join(', ')
|
|
683
|
-
return {
|
|
684
|
-
emissions: [],
|
|
685
|
-
failure: `${plural(grown.length, 'block')} past the checkpoint added or changed on this branch: ${where}. Split each at a seam, or cut it under ${CHECKPOINTS.paragraph} characters for a paragraph and ${CHECKPOINTS.bullet} for a bullet.`,
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
504
|
/**
|
|
690
505
|
* Whether a path's stem, its basename up to the first `.`, repeats its
|
|
691
506
|
* immediate parent folder, either whole or as a `<folder>-` prefix.
|
|
@@ -693,8 +508,12 @@ export const paragraphGrowth: Measure = async (ctx) => {
|
|
|
693
508
|
* Exact rather than singular-aware, so `skills/skill.md` passes. A false
|
|
694
509
|
* positive on a gate stage teaches a contributor to route around it, and a
|
|
695
510
|
* widened match is cheap to add once a real echo slips through.
|
|
511
|
+
*
|
|
512
|
+
* `canon/context/context-model.md` is exempt, since "context model" is the
|
|
513
|
+
* domain's own name and the entry reads as nothing else without the prefix.
|
|
696
514
|
*/
|
|
697
515
|
export function isFolderEchoedName(path: string): boolean {
|
|
516
|
+
if (path === 'canon/context/context-model.md') return false
|
|
698
517
|
const segments = path.split('/')
|
|
699
518
|
if (segments.length < 2) return false
|
|
700
519
|
const folder = segments[segments.length - 2] ?? ''
|
|
@@ -806,8 +625,10 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
806
625
|
}
|
|
807
626
|
|
|
808
627
|
/**
|
|
809
|
-
* A banned character
|
|
810
|
-
*
|
|
628
|
+
* A banned character, a dead link, and a density finding on a gated path are
|
|
629
|
+
* each a fact rather than a threshold, so any one fails the push while depth
|
|
630
|
+
* weight and cadence stay advisory. The audit decides which density findings
|
|
631
|
+
* gate, from `src/markdown/scope.ts`, so this stage reads its exit alone.
|
|
811
632
|
*
|
|
812
633
|
* The whole corpus is measured rather than the changed files, because a
|
|
813
634
|
* `Do not use` bullet added to a standard bans a token retroactively and no
|
|
@@ -821,7 +642,11 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
821
642
|
const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
|
|
822
643
|
|
|
823
644
|
if (exitCode === 0) {
|
|
824
|
-
return {
|
|
645
|
+
return {
|
|
646
|
+
emissions: [
|
|
647
|
+
info('No banned character, dead link, or gated density finding'),
|
|
648
|
+
],
|
|
649
|
+
}
|
|
825
650
|
}
|
|
826
651
|
|
|
827
652
|
if (exitCode === 1) {
|
|
@@ -844,7 +669,7 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
844
669
|
return {
|
|
845
670
|
emissions: [output(frame.all)],
|
|
846
671
|
failure:
|
|
847
|
-
'Markdown prose carries a banned character,
|
|
672
|
+
'Markdown prose carries a banned character, a relative link resolves to nothing on disk, or a gated path breaks a density limit. Rewrite the sentence, since a colon or a comma standing where the em dash was is the same sentence, fix the link, or split the block the Density section names, and reach for a code span only where the token is genuinely an identifier under discussion.',
|
|
848
673
|
}
|
|
849
674
|
}
|
|
850
675
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -11,12 +11,10 @@ import {
|
|
|
11
11
|
captureStamps,
|
|
12
12
|
clientCommandCitations,
|
|
13
13
|
designProse,
|
|
14
|
-
bulletStacks,
|
|
15
14
|
documentCeiling,
|
|
16
15
|
folderEchoedNames,
|
|
17
16
|
markdownBans,
|
|
18
17
|
type Measure,
|
|
19
|
-
paragraphGrowth,
|
|
20
18
|
pluginManifests,
|
|
21
19
|
readmeCitations,
|
|
22
20
|
recordIdempotence,
|
|
@@ -321,7 +319,8 @@ export const STAGES: readonly Stage[] = [
|
|
|
321
319
|
{
|
|
322
320
|
// The facts the audit can answer from the files gate: a cited path that
|
|
323
321
|
// does not resolve, a required section an entry does not declare, an H2
|
|
324
|
-
// outside the closed set of
|
|
322
|
+
// outside the closed set of three, a shape break in an entry under a
|
|
323
|
+
// shape-gated folder, and an index disagreeing with its folder.
|
|
325
324
|
// Its length, depth, bullet, table, and provenance findings are judgment
|
|
326
325
|
// thresholds, and failing a push on one would make the stage something to
|
|
327
326
|
// route around. The length readings that gate sit in their own stages
|
|
@@ -335,7 +334,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
335
334
|
kind: 'cli',
|
|
336
335
|
argv: ['context', 'audit', '--gate'],
|
|
337
336
|
failure:
|
|
338
|
-
'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout,
|
|
337
|
+
'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
|
|
339
338
|
},
|
|
340
339
|
],
|
|
341
340
|
success: 'Context citations resolve',
|
|
@@ -354,23 +353,6 @@ export const STAGES: readonly Stage[] = [
|
|
|
354
353
|
label: 'Document ceiling',
|
|
355
354
|
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
356
355
|
},
|
|
357
|
-
{
|
|
358
|
-
// Unscoped, since any branch can add a bullet to any entry. A run of more
|
|
359
|
-
// than 5 plain bullets fails the push in the context entries, the docs
|
|
360
|
-
// pages, and the skill bodies and references. Apart from the audit's exit
|
|
361
|
-
// code for the reason the Document ceiling stage above states.
|
|
362
|
-
id: 'bullet-stacks',
|
|
363
|
-
label: 'Bullet stacks',
|
|
364
|
-
checks: [{ kind: 'measure', measure: bulletStacks }],
|
|
365
|
-
},
|
|
366
|
-
{
|
|
367
|
-
// Context entries only, where length drifts. A paragraph past 700
|
|
368
|
-
// characters or a top-level bullet past 250 fails when this branch added or
|
|
369
|
-
// changed it, and an unchanged one passes until it is edited.
|
|
370
|
-
id: 'paragraph-growth',
|
|
371
|
-
label: 'Paragraph growth',
|
|
372
|
-
checks: [{ kind: 'measure', measure: paragraphGrowth }],
|
|
373
|
-
},
|
|
374
356
|
{
|
|
375
357
|
// Unscoped, since any branch can add a file anywhere and one git ls-files
|
|
376
358
|
// costs nothing. Reads paths rather than markdown, so it sits here rather
|
|
@@ -410,8 +392,10 @@ export const STAGES: readonly Stage[] = [
|
|
|
410
392
|
checks: [{ kind: 'measure', measure: recordIdempotence }],
|
|
411
393
|
},
|
|
412
394
|
{
|
|
413
|
-
|
|
414
|
-
|
|
395
|
+
// Bans and dead links in every file, and a density finding on a path the
|
|
396
|
+
// audit gates, all read off one exit code.
|
|
397
|
+
id: 'markdown',
|
|
398
|
+
label: 'Markdown',
|
|
415
399
|
checks: [{ kind: 'measure', measure: markdownBans }],
|
|
416
400
|
},
|
|
417
401
|
{
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isAbsolute, join } from 'node:path'
|
|
2
|
+
import { decodePath } from '@/markdown/links'
|
|
3
|
+
import { LINK, linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
|
|
4
|
+
|
|
5
|
+
export interface MissingLink {
|
|
6
|
+
/** The destination as the reply wrote it. */
|
|
7
|
+
readonly destination: string
|
|
8
|
+
/** The path the check looked for. */
|
|
9
|
+
readonly path: string
|
|
10
|
+
/** Where the file is instead, for a records link written under a worktree. */
|
|
11
|
+
readonly suggestion?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A bare filename followed by a line tail reads as a scheme, so a colon that
|
|
15
|
+
// opens nothing but a position (`README.md:12`) is not one.
|
|
16
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:(?!\d+(?:-\d+)?(?::\d+)?$)/i
|
|
17
|
+
const POSITION_TAIL = /(?::\d+){1,2}(?:-\d+)?$/
|
|
18
|
+
const WORKTREE_RECORDS = /\/\.claude\/worktrees\/[^/]+\/(\.canon\/.*)$/
|
|
19
|
+
|
|
20
|
+
function isSkipped(destination: string): boolean {
|
|
21
|
+
return (
|
|
22
|
+
destination === '' ||
|
|
23
|
+
SCHEME.test(destination) ||
|
|
24
|
+
destination.startsWith('#') ||
|
|
25
|
+
/[<*{]/.test(destination)
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The path a destination names, or undefined when it is not one to check.
|
|
31
|
+
*
|
|
32
|
+
* A chat link is resolved from the editor root and not from a file's own
|
|
33
|
+
* folder, which is why `findBrokenLinks` cannot serve here: it skips every
|
|
34
|
+
* absolute destination and resolves the rest beside the file holding them.
|
|
35
|
+
* A malformed percent escape is skipped, since the reply is text a model
|
|
36
|
+
* typed and the hook has no business throwing on it.
|
|
37
|
+
*/
|
|
38
|
+
function linkedPath(destination: string): string | undefined {
|
|
39
|
+
if (isSkipped(destination)) return undefined
|
|
40
|
+
const decoded = decodePath(destination)
|
|
41
|
+
if (decoded === undefined) return undefined
|
|
42
|
+
const path = decoded.replace(POSITION_TAIL, '')
|
|
43
|
+
return path === '' ? undefined : path
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Markdown link targets in a reply that name nothing on disk.
|
|
48
|
+
*
|
|
49
|
+
* A relative target resolves against `root`, the main worktree root, since the
|
|
50
|
+
* editor is rooted there. A relative link to a file that exists only inside a
|
|
51
|
+
* linked worktree therefore reads as missing, which is the report the output
|
|
52
|
+
* rule asks for. `exists` stays injected so a test asserts against a fake disk.
|
|
53
|
+
*/
|
|
54
|
+
export function findMissingReplyLinks(
|
|
55
|
+
reply: string,
|
|
56
|
+
root: string,
|
|
57
|
+
exists: (path: string) => boolean,
|
|
58
|
+
): MissingLink[] {
|
|
59
|
+
const checked = new Map<string, MissingLink | undefined>()
|
|
60
|
+
|
|
61
|
+
for (const line of linesOutsideFences(reply)) {
|
|
62
|
+
for (const match of maskCodeSpans(line).matchAll(LINK)) {
|
|
63
|
+
const destination = match[0].slice(match[0].indexOf('](') + 2, -1)
|
|
64
|
+
const target = linkedPath(destination)
|
|
65
|
+
if (target === undefined) continue
|
|
66
|
+
|
|
67
|
+
const path = isAbsolute(target) ? target : join(root, target)
|
|
68
|
+
if (checked.has(path)) continue
|
|
69
|
+
|
|
70
|
+
checked.set(
|
|
71
|
+
path,
|
|
72
|
+
exists(path) ? undefined : missing(destination, path, root, exists),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...checked.values()].filter((entry) => entry !== undefined)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The block reason the wrapper hands back to the session. The closing line
|
|
82
|
+
* covers the one miss that is not a typo: a relative link to a file living only
|
|
83
|
+
* inside a linked worktree, which the editor, rooted at the main checkout,
|
|
84
|
+
* cannot open.
|
|
85
|
+
*/
|
|
86
|
+
export function formatMissing(found: readonly MissingLink[]): string {
|
|
87
|
+
return [
|
|
88
|
+
'The final reply links to paths that do not exist on disk:',
|
|
89
|
+
...found.map((entry) =>
|
|
90
|
+
entry.suggestion === undefined
|
|
91
|
+
? `- ${entry.destination}`
|
|
92
|
+
: `- ${entry.destination} (the file is at ${entry.suggestion})`,
|
|
93
|
+
),
|
|
94
|
+
'',
|
|
95
|
+
'Post the corrected links in a short follow-up. Use the full path from the project root, and the absolute path for a file inside a linked worktree.',
|
|
96
|
+
].join('\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function missing(
|
|
100
|
+
destination: string,
|
|
101
|
+
path: string,
|
|
102
|
+
root: string,
|
|
103
|
+
exists: (path: string) => boolean,
|
|
104
|
+
): MissingLink {
|
|
105
|
+
const records = WORKTREE_RECORDS.exec(path)?.[1]
|
|
106
|
+
const relocated = records === undefined ? undefined : join(root, records)
|
|
107
|
+
return relocated !== undefined && exists(relocated)
|
|
108
|
+
? { destination, path, suggestion: relocated }
|
|
109
|
+
: { destination, path }
|
|
110
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { type BodyLine, visibleText } from '@/markdown/scan'
|
|
2
|
+
import { bulletStacks, CAP } from '@/markdown/stacks'
|
|
3
|
+
import {
|
|
4
|
+
BLOCKQUOTE,
|
|
5
|
+
CHECKPOINTS,
|
|
6
|
+
HEADING,
|
|
7
|
+
isSectionMarker,
|
|
8
|
+
LIST_ITEM,
|
|
9
|
+
TABLE_ROW,
|
|
10
|
+
} from '@/markdown/structure'
|
|
11
|
+
|
|
12
|
+
/** The most prose paragraphs one stretch may carry before a list, table, or heading breaks it. */
|
|
13
|
+
export const PARAGRAPH_RUN = 2
|
|
14
|
+
|
|
15
|
+
/** The deepest heading level a document may use. */
|
|
16
|
+
export const HEADING_DEPTH = 3
|
|
17
|
+
|
|
18
|
+
export type DensityKind =
|
|
19
|
+
| 'paragraph-length'
|
|
20
|
+
| 'paragraph-run'
|
|
21
|
+
| 'bullet-length'
|
|
22
|
+
| 'bullet-run'
|
|
23
|
+
| 'heading-depth'
|
|
24
|
+
| 'h3-opening'
|
|
25
|
+
|
|
26
|
+
export interface DensityFinding {
|
|
27
|
+
readonly kind: DensityKind
|
|
28
|
+
/** The line of the block that breaches, or of a run's first block. */
|
|
29
|
+
readonly line: number
|
|
30
|
+
/** What was measured, or null for the H3 opening, which is a shape rather than a count. */
|
|
31
|
+
readonly value: number | null
|
|
32
|
+
readonly limit: number | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A block as a reader meets it. `other` is everything that is neither prose,
|
|
37
|
+
* a list item, nor a heading, being a table, a fence, a blockquote, a section
|
|
38
|
+
* marker, and raw HTML, and each one breaks a paragraph run.
|
|
39
|
+
*/
|
|
40
|
+
type Block =
|
|
41
|
+
| { readonly kind: 'heading'; readonly line: number; readonly level: number }
|
|
42
|
+
| { readonly kind: 'paragraph'; readonly line: number; readonly text: string }
|
|
43
|
+
| { readonly kind: 'item'; readonly line: number; readonly text: string }
|
|
44
|
+
| { readonly kind: 'other'; readonly line: number; readonly table: boolean }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Splits the body into the blocks the six limits read.
|
|
48
|
+
*
|
|
49
|
+
* Its own walk rather than `paragraphBlocks` in `src/markdown/structure.ts`,
|
|
50
|
+
* because the two disagree on one construct and the disagreement is the point.
|
|
51
|
+
* That walk reads an indented paragraph under a list item as a paragraph, and
|
|
52
|
+
* here it belongs to the item: it counts toward the bullet's length and never
|
|
53
|
+
* toward a paragraph run, since a reader sees it inside the list.
|
|
54
|
+
*
|
|
55
|
+
* A nested item is left out of its parent, as `heavyBullets` leaves it, and so
|
|
56
|
+
* is everything indented under the nested item. An indented fence inside an
|
|
57
|
+
* item stays with the item, and a column-zero one ends it.
|
|
58
|
+
*/
|
|
59
|
+
function blocksOf(lines: readonly BodyLine[]): Block[] {
|
|
60
|
+
const blocks: Block[] = []
|
|
61
|
+
let paragraph: { line: number; parts: string[] } | undefined
|
|
62
|
+
let item: { line: number; parts: string[]; nested: boolean } | undefined
|
|
63
|
+
let inFence = false
|
|
64
|
+
let afterBlank = false
|
|
65
|
+
|
|
66
|
+
const closeParagraph = (): void => {
|
|
67
|
+
if (paragraph) {
|
|
68
|
+
blocks.push({
|
|
69
|
+
kind: 'paragraph',
|
|
70
|
+
line: paragraph.line,
|
|
71
|
+
text: paragraph.parts.join(' '),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
paragraph = undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const closeItem = (): void => {
|
|
78
|
+
if (item) {
|
|
79
|
+
blocks.push({ kind: 'item', line: item.line, text: item.parts.join(' ') })
|
|
80
|
+
}
|
|
81
|
+
item = undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const closeAll = (): void => {
|
|
85
|
+
closeParagraph()
|
|
86
|
+
closeItem()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const raw = line.text
|
|
91
|
+
const text = raw.trim()
|
|
92
|
+
const indented = /^\s/.test(raw)
|
|
93
|
+
|
|
94
|
+
if (line.fenced) {
|
|
95
|
+
// The opening delimiter decides who owns the block. `bodyLines` marks a
|
|
96
|
+
// delimiter and the body alike, so the opener is the first fenced line.
|
|
97
|
+
if (!inFence) {
|
|
98
|
+
if (!(item && indented)) {
|
|
99
|
+
closeAll()
|
|
100
|
+
blocks.push({ kind: 'other', line: line.number, table: false })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
inFence = true
|
|
104
|
+
afterBlank = false
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
inFence = false
|
|
108
|
+
|
|
109
|
+
if (text === '') {
|
|
110
|
+
closeParagraph()
|
|
111
|
+
afterBlank = true
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (indented && item) {
|
|
116
|
+
if (LIST_ITEM.test(raw)) item.nested = true
|
|
117
|
+
else if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
|
|
118
|
+
afterBlank = false
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (HEADING.test(raw)) {
|
|
123
|
+
closeAll()
|
|
124
|
+
blocks.push({
|
|
125
|
+
kind: 'heading',
|
|
126
|
+
line: line.number,
|
|
127
|
+
level: (raw.match(/^#+/)?.[0] ?? '').length,
|
|
128
|
+
})
|
|
129
|
+
} else if (!indented && LIST_ITEM.test(raw)) {
|
|
130
|
+
closeAll()
|
|
131
|
+
item = {
|
|
132
|
+
line: line.number,
|
|
133
|
+
parts: [text.replace(LIST_ITEM, '')],
|
|
134
|
+
nested: false,
|
|
135
|
+
}
|
|
136
|
+
} else if (
|
|
137
|
+
TABLE_ROW.test(raw) ||
|
|
138
|
+
BLOCKQUOTE.test(raw) ||
|
|
139
|
+
isSectionMarker(raw) ||
|
|
140
|
+
text.startsWith('<')
|
|
141
|
+
) {
|
|
142
|
+
closeAll()
|
|
143
|
+
blocks.push({
|
|
144
|
+
kind: 'other',
|
|
145
|
+
line: line.number,
|
|
146
|
+
table: TABLE_ROW.test(raw),
|
|
147
|
+
})
|
|
148
|
+
} else if (paragraph) {
|
|
149
|
+
paragraph.parts.push(text)
|
|
150
|
+
} else if (item && !afterBlank) {
|
|
151
|
+
// A lazy continuation, which renders inside the item above it.
|
|
152
|
+
item.parts.push(text)
|
|
153
|
+
} else {
|
|
154
|
+
closeItem()
|
|
155
|
+
paragraph = { line: line.number, parts: [text] }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
afterBlank = false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
closeAll()
|
|
162
|
+
|
|
163
|
+
return blocks
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every breach of the six density limits, in line order.
|
|
168
|
+
*
|
|
169
|
+
* Each limit is a count rather than a judgment, so a finding is a fact a scan
|
|
170
|
+
* settles and the caller decides whether it gates. The limits are a 350-character
|
|
171
|
+
* paragraph, 2 paragraphs in a row, a 200-character bullet, 5 plain bullets in
|
|
172
|
+
* a row, no heading past H3, and a paragraph or a table as the first block
|
|
173
|
+
* under every H3. The two length numbers are `CHECKPOINTS`, so the advisory
|
|
174
|
+
* Bullets and Paragraphs sections and this measure read one number, and the
|
|
175
|
+
* bullet run is `bulletStacks`, so a bold-labeled run stays exempt here too.
|
|
176
|
+
*/
|
|
177
|
+
export function densityFindings(lines: readonly BodyLine[]): DensityFinding[] {
|
|
178
|
+
const found: DensityFinding[] = []
|
|
179
|
+
const blocks = blocksOf(lines)
|
|
180
|
+
let run: { line: number; count: number } | undefined
|
|
181
|
+
|
|
182
|
+
const closeRun = (): void => {
|
|
183
|
+
if (run && run.count > PARAGRAPH_RUN) {
|
|
184
|
+
found.push({
|
|
185
|
+
kind: 'paragraph-run',
|
|
186
|
+
line: run.line,
|
|
187
|
+
value: run.count,
|
|
188
|
+
limit: PARAGRAPH_RUN,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
run = undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [index, block] of blocks.entries()) {
|
|
195
|
+
if (block.kind !== 'paragraph') closeRun()
|
|
196
|
+
|
|
197
|
+
if (block.kind === 'paragraph') {
|
|
198
|
+
run ??= { line: block.line, count: 0 }
|
|
199
|
+
run.count++
|
|
200
|
+
const length = visibleText(block.text).length
|
|
201
|
+
if (length > CHECKPOINTS.paragraph) {
|
|
202
|
+
found.push({
|
|
203
|
+
kind: 'paragraph-length',
|
|
204
|
+
line: block.line,
|
|
205
|
+
value: length,
|
|
206
|
+
limit: CHECKPOINTS.paragraph,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
} else if (block.kind === 'item') {
|
|
210
|
+
const length = visibleText(block.text).length
|
|
211
|
+
if (length > CHECKPOINTS.bullet) {
|
|
212
|
+
found.push({
|
|
213
|
+
kind: 'bullet-length',
|
|
214
|
+
line: block.line,
|
|
215
|
+
value: length,
|
|
216
|
+
limit: CHECKPOINTS.bullet,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
} else if (block.kind === 'heading') {
|
|
220
|
+
if (block.level > HEADING_DEPTH) {
|
|
221
|
+
found.push({
|
|
222
|
+
kind: 'heading-depth',
|
|
223
|
+
line: block.line,
|
|
224
|
+
value: block.level,
|
|
225
|
+
limit: HEADING_DEPTH,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
if (block.level === 3 && !opensWithLead(blocks[index + 1])) {
|
|
229
|
+
found.push({
|
|
230
|
+
kind: 'h3-opening',
|
|
231
|
+
line: block.line,
|
|
232
|
+
value: null,
|
|
233
|
+
limit: null,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
closeRun()
|
|
240
|
+
|
|
241
|
+
for (const stack of bulletStacks(lines)) {
|
|
242
|
+
found.push({
|
|
243
|
+
kind: 'bullet-run',
|
|
244
|
+
line: stack.line,
|
|
245
|
+
value: stack.count,
|
|
246
|
+
limit: CAP,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return found.sort((a, b) => a.line - b.line)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether the block under an H3 is the paragraph or table the limit asks for. */
|
|
254
|
+
function opensWithLead(next: Block | undefined): boolean {
|
|
255
|
+
if (next === undefined) return false
|
|
256
|
+
if (next.kind === 'paragraph') return true
|
|
257
|
+
return next.kind === 'other' && next.table
|
|
258
|
+
}
|