@erclx/canon 5.30.0 → 5.31.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/build-in-slices/REQUIREMENT.md +1 -1
- package/claude/skills/build-in-slices/references/adopted.md +1 -1
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -1
- package/claude/skills/create-standard/SKILL.md +1 -1
- package/claude/skills/design-extract/SKILL.md +2 -2
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +1 -1
- package/claude/skills/draft-doc/SKILL.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-figure/references/mermaid.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-stage/SKILL.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +23 -0
- package/claude/skills/markdown-craft/SKILL.md +17 -0
- package/{standards → claude/skills/markdown-craft/references}/markdown.md +14 -7
- package/claude/skills/memory-capture/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +1 -1
- package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
- package/claude/skills/plan-feature/SKILL.md +12 -2
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/review-ui/references/post.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
- package/claude/skills/target-setup/references/indexes.md +1 -1
- package/claude/skills/test-first/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/commands.md +5 -4
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/routing.md +1 -1
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/test-order.md +1 -1
- package/docs/agents/upstream.md +70 -0
- package/docs/workflow/skill-map-on-request.md +1 -1
- package/governance/renames.toml +0 -4
- package/governance/rules/standards/plan.md +1 -0
- package/governance/rules/writing/markdown.md +23 -0
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/claude/cases/authoring.ts +5 -0
- package/src/claude/cases/misc.ts +0 -4
- package/src/claude/skills-reach.ts +1 -1
- package/src/cli/help.ts +2 -0
- package/src/cli.ts +2 -0
- package/src/commands/claude.ts +1 -1
- package/src/commands/gov.ts +2 -2
- package/src/commands/records.ts +19 -1
- package/src/commands/upstream.ts +295 -0
- package/src/context/audit.ts +1 -1
- package/src/gate/stages.ts +0 -1
- package/src/markdown/structure.ts +1 -1
- package/src/records/backup.ts +1 -0
- package/src/records/validate.ts +55 -0
- package/src/roots/record.ts +1 -0
- package/src/standards/read.ts +21 -1
- package/src/tasks/answers.ts +14 -1
- package/src/tasks/stack.ts +176 -0
- package/src/upstream/catalog.ts +112 -0
- package/src/upstream/cursor.ts +120 -0
- package/src/upstream/digest.ts +75 -0
- package/src/upstream/releases.ts +169 -0
- package/standards/index.md +0 -1
- package/standards/plan.md +25 -12
- package/standards/rule.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
- package/claude/skills/index-lookup/REQUIREMENT.md +0 -35
- package/claude/skills/index-lookup/SKILL.md +0 -44
- package/governance/rules/canon/601-indexes.md +0 -9
- package/governance/rules/claude/565-behavior.md +0 -27
- package/governance/rules/claude/567-planning.md +0 -16
- package/governance/rules/claude/575-hooks.md +0 -16
- package/governance/rules/claude/576-settings.md +0 -13
- package/governance/rules/claude/592-claude-md.md +0 -19
- package/governance/rules/standards/markdown.md +0 -16
- package/governance/rules/writing/800-prose.md +0 -15
package/src/records/validate.ts
CHANGED
|
@@ -75,6 +75,8 @@ export const FINDING_KINDS = [
|
|
|
75
75
|
'category-mismatch',
|
|
76
76
|
'operator-call-phrasing',
|
|
77
77
|
'batch-unsplit',
|
|
78
|
+
'source-unquoted',
|
|
79
|
+
'stack-foldable',
|
|
78
80
|
] as const
|
|
79
81
|
|
|
80
82
|
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
@@ -276,6 +278,7 @@ const PLAN_SECTIONS = [
|
|
|
276
278
|
'Risks',
|
|
277
279
|
'Review focus',
|
|
278
280
|
'Questions',
|
|
281
|
+
'Sources',
|
|
279
282
|
] as const
|
|
280
283
|
|
|
281
284
|
type PlanSection = (typeof PLAN_SECTIONS)[number]
|
|
@@ -425,6 +428,57 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
|
425
428
|
return findings
|
|
426
429
|
}
|
|
427
430
|
|
|
431
|
+
const SOURCE_LINK = /\[[^\]]+\]\(https?:\/\/[^)\s]+\)|https?:\/\/\S+/
|
|
432
|
+
const SOURCE_QUOTE = /["“][^"“”]+["”]/
|
|
433
|
+
const SOURCE_UNVERIFIED = /\bunverified\b/i
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A bullet opens an entry only at the section's shallowest indent, so a nested
|
|
437
|
+
* bullet and a wrapped line both belong to the entry above. Joining them first
|
|
438
|
+
* lets a passage wrap across lines and still count as quoted.
|
|
439
|
+
*/
|
|
440
|
+
function readSourceEntries(lines: readonly string[]): string[] {
|
|
441
|
+
const entries: string[] = []
|
|
442
|
+
let depth: number | undefined
|
|
443
|
+
|
|
444
|
+
for (const line of lines) {
|
|
445
|
+
const trimmed = line.trim()
|
|
446
|
+
if (trimmed === '') continue
|
|
447
|
+
|
|
448
|
+
const indent = line.length - line.trimStart().length
|
|
449
|
+
const opensEntry =
|
|
450
|
+
trimmed.startsWith('- ') && (depth === undefined || indent <= depth)
|
|
451
|
+
|
|
452
|
+
if (opensEntry) {
|
|
453
|
+
depth = indent
|
|
454
|
+
entries.push(trimmed)
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (entries.length > 0) entries[entries.length - 1] += ` ${trimmed}`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return entries
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function checkSources(name: string, lines: readonly string[]): Finding[] {
|
|
465
|
+
return readSourceEntries(lines)
|
|
466
|
+
.filter((entry) => entry !== `- ${NONE_IDENTIFIED}`)
|
|
467
|
+
.filter(
|
|
468
|
+
(entry) =>
|
|
469
|
+
!SOURCE_LINK.test(entry) ||
|
|
470
|
+
!(SOURCE_QUOTE.test(entry) || SOURCE_UNVERIFIED.test(entry)),
|
|
471
|
+
)
|
|
472
|
+
.map((entry) =>
|
|
473
|
+
finding(
|
|
474
|
+
'source-unquoted',
|
|
475
|
+
name,
|
|
476
|
+
shorten(entry),
|
|
477
|
+
'carries no link, or a link with neither a double-quoted passage nor the word unverified.',
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
428
482
|
export function checkPlan(name: string, text: string): Finding[] {
|
|
429
483
|
const findings: Finding[] = []
|
|
430
484
|
|
|
@@ -480,6 +534,7 @@ export function checkPlan(name: string, text: string): Finding[] {
|
|
|
480
534
|
}
|
|
481
535
|
|
|
482
536
|
findings.push(...checkQuestionContract(name, sections.get('Questions') ?? []))
|
|
537
|
+
findings.push(...checkSources(name, sections.get('Sources') ?? []))
|
|
483
538
|
|
|
484
539
|
if (hasStagedBatches(text)) {
|
|
485
540
|
const staged = linesOutsideFences(text).find((line) =>
|
package/src/roots/record.ts
CHANGED
package/src/standards/read.ts
CHANGED
|
@@ -53,6 +53,15 @@ export function standardRoots(root: string): StandardRoot[] {
|
|
|
53
53
|
]
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Standards retired into the skill reference that took them, keyed by name with
|
|
58
|
+
* a path relative to the package root. Each retirement adds one line, and the
|
|
59
|
+
* map goes with the verb in the last slice of the retirement.
|
|
60
|
+
*/
|
|
61
|
+
const MOVED_STANDARDS: Readonly<Record<string, string>> = {
|
|
62
|
+
markdown: 'claude/skills/markdown-craft/references/markdown.md',
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
function filename(name: string): string {
|
|
57
66
|
return name.endsWith('.md') ? name : `${name}.md`
|
|
58
67
|
}
|
|
@@ -69,7 +78,16 @@ export function resolveStandard(
|
|
|
69
78
|
if (existsSync(path)) return { path, source: join(label, file) }
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
const movedName = basename(file, '.md')
|
|
82
|
+
if (!Object.hasOwn(MOVED_STANDARDS, movedName)) return undefined
|
|
83
|
+
|
|
84
|
+
const moved = MOVED_STANDARDS[movedName]
|
|
85
|
+
if (!moved) return undefined
|
|
86
|
+
|
|
87
|
+
const path = join(PROJECT_ROOT, moved)
|
|
88
|
+
if (!existsSync(path)) return undefined
|
|
89
|
+
|
|
90
|
+
return { path, source: join(PACKAGE_LABEL, moved) }
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
/**
|
|
@@ -92,6 +110,8 @@ export function listStandards(root: string): string[] {
|
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
112
|
|
|
113
|
+
for (const name of Object.keys(MOVED_STANDARDS)) names.add(name)
|
|
114
|
+
|
|
95
115
|
return [...names].sort()
|
|
96
116
|
}
|
|
97
117
|
|
package/src/tasks/answers.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { isAbsolute, relative, resolve } from 'node:path'
|
|
3
|
+
import { basename, isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { isUnder } from '@/tasks/paths'
|
|
5
|
+
import { JUDGED_APART_REMEDY, readLivePlans, stackPairs } from '@/tasks/stack'
|
|
5
6
|
import { recordDir, recordDirs } from '@/roots/record'
|
|
6
7
|
import {
|
|
7
8
|
hasStagedBatches,
|
|
@@ -231,6 +232,18 @@ export async function planAnswers(
|
|
|
231
232
|
})
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
const stem = basename(resolved.path, '.md')
|
|
236
|
+
const pairs = stackPairs(await readLivePlans(root))
|
|
237
|
+
|
|
238
|
+
for (const { child, parent, shared } of pairs) {
|
|
239
|
+
if (child !== stem) continue
|
|
240
|
+
|
|
241
|
+
open.push({
|
|
242
|
+
label: 'Stacked sibling',
|
|
243
|
+
why: `stacks on \`${parent}\` and shares ${shared} declared files with it. ${JUDGED_APART_REMEDY}`,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
234
247
|
return {
|
|
235
248
|
ok: true,
|
|
236
249
|
plan: resolved.plan,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import { recordDir } from '@/roots/record'
|
|
5
|
+
import type { Finding } from '@/records/validate'
|
|
6
|
+
import { splitPlanSections } from '@/records/validate'
|
|
7
|
+
import { readDeclarations } from '@/tasks/reach'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The fold thresholds, calibrated over 1,043 live and archived plans in which
|
|
11
|
+
* 87 stack pairs name a parent. At these values 15 report: the inspector,
|
|
12
|
+
* canvas, slides, and evidence chains, plus one sandbox contract split that
|
|
13
|
+
* carries a `Judged apart from` line. The ceiling drops one pair of 24 files.
|
|
14
|
+
* The per-PR cost was never measured as a number, so these are calibrated
|
|
15
|
+
* against the corpus and not derived.
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_SHARED = 3
|
|
18
|
+
export const MIN_OVERLAP = 0.6
|
|
19
|
+
export const MAX_UNION = 20
|
|
20
|
+
|
|
21
|
+
const STACK_PARENT =
|
|
22
|
+
/(?:stacks? on|builds? on|branch(?:es)? from)\s+(?:(?:the|batch|plan|slice)\s+)*`(feature-[a-z0-9-]+)`/gi
|
|
23
|
+
|
|
24
|
+
const JUDGED_APART = /^judged apart from\s+`(feature-[a-z0-9-]+)`:\s*\S/i
|
|
25
|
+
|
|
26
|
+
const BULLET = /^[-*]\s+/
|
|
27
|
+
|
|
28
|
+
export interface StackPlan {
|
|
29
|
+
readonly stem: string
|
|
30
|
+
readonly text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StackPair {
|
|
34
|
+
readonly child: string
|
|
35
|
+
readonly parent: string
|
|
36
|
+
readonly shared: number
|
|
37
|
+
readonly union: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One string per `**Constraints:**` bullet, with a wrapped continuation line
|
|
42
|
+
* joined to the bullet it belongs to.
|
|
43
|
+
*/
|
|
44
|
+
function constraintBullets(text: string): string[] {
|
|
45
|
+
const bullets: string[] = []
|
|
46
|
+
|
|
47
|
+
for (const line of splitPlanSections(text).get('Constraints') ?? []) {
|
|
48
|
+
const trimmed = line.trim()
|
|
49
|
+
if (trimmed.length === 0) continue
|
|
50
|
+
|
|
51
|
+
if (BULLET.test(trimmed)) bullets.push(trimmed.replace(BULLET, ''))
|
|
52
|
+
else if (bullets.length > 0) bullets[bullets.length - 1] += ` ${trimmed}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return bullets
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The plan stems a plan says it stacks on, in the order written.
|
|
60
|
+
*
|
|
61
|
+
* Only the slug standing directly after the phrase counts, with at most a
|
|
62
|
+
* filler word between. A line like "`feature-x` stacks on this slice, not on
|
|
63
|
+
* `feature-y`" names a parent for `feature-y`'s plan and none for this one, and
|
|
64
|
+
* reading every slug on a stack-worded line would hand it the wrong parent.
|
|
65
|
+
*/
|
|
66
|
+
export function readStackParents(text: string): string[] {
|
|
67
|
+
const parents: string[] = []
|
|
68
|
+
|
|
69
|
+
for (const bullet of constraintBullets(text)) {
|
|
70
|
+
for (const match of bullet.matchAll(STACK_PARENT)) {
|
|
71
|
+
const parent = match[1]
|
|
72
|
+
if (parent !== undefined) parents.push(parent.toLowerCase())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...new Set(parents)]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isJudgedApart(text: string, parent: string): boolean {
|
|
80
|
+
return constraintBullets(text).some(
|
|
81
|
+
(bullet) => JUDGED_APART.exec(bullet)?.[1]?.toLowerCase() === parent,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether two declarations name the same file or one folder holding the other. */
|
|
86
|
+
function overlaps(a: string, b: string): boolean {
|
|
87
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function countShared(from: readonly string[], to: readonly string[]): number {
|
|
91
|
+
return from.filter((entry) => to.some((other) => overlaps(entry, other)))
|
|
92
|
+
.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every live plan that stacks on a live sibling it mostly shares files with and
|
|
97
|
+
* has not written down why the two are judged apart.
|
|
98
|
+
*
|
|
99
|
+
* A parent absent from `plans` is never reported, which is how an archived
|
|
100
|
+
* parent stays clear: the caller hands over the live folder only, so a merged
|
|
101
|
+
* parent cannot be folded into. When two plans each name the other, the pair is
|
|
102
|
+
* reported once, on the child sorting second.
|
|
103
|
+
*
|
|
104
|
+
* A folder declaration counts as sharing the files under it, taking the larger
|
|
105
|
+
* of the two directions and capping at the smaller set, so a folder and the
|
|
106
|
+
* files beneath it are not undercounted as distinct strings.
|
|
107
|
+
*/
|
|
108
|
+
export function stackPairs(plans: readonly StackPlan[]): StackPair[] {
|
|
109
|
+
const byStem = new Map(plans.map((plan) => [plan.stem, plan]))
|
|
110
|
+
const pairs: StackPair[] = []
|
|
111
|
+
|
|
112
|
+
for (const child of plans) {
|
|
113
|
+
for (const parentStem of readStackParents(child.text)) {
|
|
114
|
+
const parent = byStem.get(parentStem)
|
|
115
|
+
if (parent === undefined || parent.stem === child.stem) continue
|
|
116
|
+
|
|
117
|
+
const mutual = readStackParents(parent.text).includes(child.stem)
|
|
118
|
+
if (mutual && child.stem < parent.stem) continue
|
|
119
|
+
|
|
120
|
+
if (isJudgedApart(child.text, parentStem)) continue
|
|
121
|
+
|
|
122
|
+
const own = readDeclarations(child.text)
|
|
123
|
+
const theirs = readDeclarations(parent.text)
|
|
124
|
+
const smaller = Math.min(own.length, theirs.length)
|
|
125
|
+
if (smaller === 0) continue
|
|
126
|
+
|
|
127
|
+
const shared = Math.min(
|
|
128
|
+
smaller,
|
|
129
|
+
Math.max(countShared(own, theirs), countShared(theirs, own)),
|
|
130
|
+
)
|
|
131
|
+
const union = new Set([...own, ...theirs]).size
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
shared >= MIN_SHARED &&
|
|
135
|
+
shared / smaller >= MIN_OVERLAP &&
|
|
136
|
+
union <= MAX_UNION
|
|
137
|
+
) {
|
|
138
|
+
pairs.push({ child: child.stem, parent: parentStem, shared, union })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return pairs
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The `Judged apart from` remedy, worded once for the finding and the gate. */
|
|
147
|
+
export const JUDGED_APART_REMEDY =
|
|
148
|
+
'Fold the batch into its parent as contiguous commits on one branch, or write `Judged apart from `feature-<parent>`: <reason>` in its constraints.'
|
|
149
|
+
|
|
150
|
+
export function stackFindings(plans: readonly StackPlan[]): Finding[] {
|
|
151
|
+
return stackPairs(plans).map(({ child, parent, shared, union }) => ({
|
|
152
|
+
kind: 'stack-foldable',
|
|
153
|
+
record: `${child}.md`,
|
|
154
|
+
subject: parent,
|
|
155
|
+
message: `stacks on \`${parent}\` and shares ${shared} declared files with it, ${union} between the two. ${JUDGED_APART_REMEDY}`,
|
|
156
|
+
}))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The live plans folder as stem and text pairs. The archive is a folder below
|
|
161
|
+
* this one and is never walked, so a merged parent is absent and stays clear.
|
|
162
|
+
*/
|
|
163
|
+
export async function readLivePlans(root: string): Promise<StackPlan[]> {
|
|
164
|
+
const dir = recordDir(root, 'plans')
|
|
165
|
+
if (!existsSync(dir)) return []
|
|
166
|
+
|
|
167
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith('.md'))
|
|
168
|
+
names.sort()
|
|
169
|
+
|
|
170
|
+
return Promise.all(
|
|
171
|
+
names.map(async (name) => ({
|
|
172
|
+
stem: basename(name, '.md'),
|
|
173
|
+
text: await readFile(join(dir, name), 'utf8'),
|
|
174
|
+
})),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { COMMAND_GROUPS } from '@/cli/help'
|
|
4
|
+
import { listSkillsAt } from '@/claude/skills-list'
|
|
5
|
+
import { STAGES } from '@/gate/stages'
|
|
6
|
+
|
|
7
|
+
const MAX_TEXT = 140
|
|
8
|
+
const PLUGIN_SKILLS = 'claude/skills'
|
|
9
|
+
const INTERNAL_SKILLS = '.claude/skills'
|
|
10
|
+
|
|
11
|
+
export interface CatalogEntry {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly text: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Catalog {
|
|
17
|
+
readonly skills: CatalogEntry[]
|
|
18
|
+
readonly internalSkills: CatalogEntry[]
|
|
19
|
+
readonly verbs: CatalogEntry[]
|
|
20
|
+
readonly hooks: CatalogEntry[]
|
|
21
|
+
readonly gate: CatalogEntry[]
|
|
22
|
+
/** Rows that exist but could not be described, so none drops out silently. */
|
|
23
|
+
readonly gaps: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const firstSentence = (text: string): string =>
|
|
27
|
+
text.split(/\.\s/)[0]?.replace(/\.$/, '').slice(0, MAX_TEXT) ?? ''
|
|
28
|
+
|
|
29
|
+
function skillsAt(
|
|
30
|
+
root: string,
|
|
31
|
+
corpus: string,
|
|
32
|
+
gaps: string[],
|
|
33
|
+
): CatalogEntry[] {
|
|
34
|
+
const dir = join(root, corpus)
|
|
35
|
+
if (!existsSync(dir)) return []
|
|
36
|
+
|
|
37
|
+
const listed = listSkillsAt(dir)
|
|
38
|
+
const named = new Set(listed.map((skill) => skill.name))
|
|
39
|
+
const folders = readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isDirectory())
|
|
41
|
+
.map((entry) => entry.name)
|
|
42
|
+
|
|
43
|
+
for (const folder of folders) {
|
|
44
|
+
if (!named.has(folder)) gaps.push(`${corpus}/${folder}: no SKILL.md`)
|
|
45
|
+
}
|
|
46
|
+
for (const skill of listed) {
|
|
47
|
+
if (!skill.description) gaps.push(`${corpus}/${skill.name}: no description`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return listed.map((skill) => ({
|
|
51
|
+
name: skill.name,
|
|
52
|
+
text: firstSentence(skill.description),
|
|
53
|
+
}))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hooksAt(root: string): CatalogEntry[] {
|
|
57
|
+
const dir = join(root, '.claude', 'hooks')
|
|
58
|
+
if (!existsSync(dir)) return []
|
|
59
|
+
|
|
60
|
+
return readdirSync(dir)
|
|
61
|
+
.filter((file) => file.endsWith('.sh'))
|
|
62
|
+
.sort()
|
|
63
|
+
.map((file) => {
|
|
64
|
+
const comment = readFileSync(join(dir, file), 'utf8')
|
|
65
|
+
.split('\n')
|
|
66
|
+
.find((line) => /^#\s*\S/.test(line) && !line.startsWith('#!'))
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: file,
|
|
70
|
+
text: (comment ?? '').replace(/^#\s*/, '').slice(0, MAX_TEXT),
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildCatalog(root: string): Catalog {
|
|
76
|
+
const gaps: string[] = []
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
skills: skillsAt(root, PLUGIN_SKILLS, gaps),
|
|
80
|
+
internalSkills: skillsAt(root, INTERNAL_SKILLS, gaps),
|
|
81
|
+
verbs: COMMAND_GROUPS.flatMap((group) => group.rows).map(
|
|
82
|
+
([signature, description]) => ({
|
|
83
|
+
name: `canon ${signature}`,
|
|
84
|
+
text: description,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
hooks: hooksAt(root),
|
|
88
|
+
gate: STAGES.map((stage) => ({ name: stage.id, text: stage.label })),
|
|
89
|
+
gaps,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const section = (title: string, entries: readonly CatalogEntry[]): string[] => [
|
|
94
|
+
`### ${title}`,
|
|
95
|
+
...entries.map((entry) =>
|
|
96
|
+
entry.text ? `- ${entry.name}: ${entry.text}` : `- ${entry.name}`,
|
|
97
|
+
),
|
|
98
|
+
'',
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
export function renderCatalog(catalog: Catalog): string {
|
|
102
|
+
return [
|
|
103
|
+
...section('Plugin skills (claude/skills/)', catalog.skills),
|
|
104
|
+
...section('Internal skills (.claude/skills/)', catalog.internalSkills),
|
|
105
|
+
...section('CLI verbs', catalog.verbs),
|
|
106
|
+
...section('Hooks (.claude/hooks/)', catalog.hooks),
|
|
107
|
+
...section('Gate stages (canon gate run)', catalog.gate),
|
|
108
|
+
...(catalog.gaps.length > 0
|
|
109
|
+
? ['### Gaps', ...catalog.gaps.map((gap) => `- ${gap}`), '']
|
|
110
|
+
: []),
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
import { recordDir } from '@/roots/record'
|
|
10
|
+
|
|
11
|
+
const FOLDER = 'upstream'
|
|
12
|
+
const VERSION = /^\d+(\.\d+)*$/
|
|
13
|
+
|
|
14
|
+
export interface Cursor {
|
|
15
|
+
readonly version: string
|
|
16
|
+
readonly date: string
|
|
17
|
+
readonly intake: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdvanceInput {
|
|
21
|
+
readonly version: string
|
|
22
|
+
readonly intake: string
|
|
23
|
+
readonly today: string
|
|
24
|
+
/** The current `llms.txt`, or null to keep the copy already stored. */
|
|
25
|
+
readonly llms: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AdvanceOutcome =
|
|
29
|
+
| { readonly kind: 'advanced' }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'refused'
|
|
32
|
+
readonly reason: 'older-than-cursor' | 'invalid-version'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LlmsDifference {
|
|
36
|
+
readonly added: string[]
|
|
37
|
+
readonly removed: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cursorPath = (root: string): string =>
|
|
41
|
+
recordDir(root, FOLDER, 'cursor.json')
|
|
42
|
+
|
|
43
|
+
const llmsPath = (root: string): string => recordDir(root, FOLDER, 'llms.txt')
|
|
44
|
+
|
|
45
|
+
export function readCursor(root: string): Cursor | null {
|
|
46
|
+
const path = cursorPath(root)
|
|
47
|
+
if (!existsSync(path)) return null
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Cursor
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readLastLlms(root: string): string | null {
|
|
57
|
+
const path = llmsPath(root)
|
|
58
|
+
|
|
59
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function compareVersions(a: string, b: string): number {
|
|
63
|
+
const left = a.split('.').map(Number)
|
|
64
|
+
const right = b.split('.').map(Number)
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
67
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
68
|
+
if (diff !== 0) return diff
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `fetch` reads this file, so a write that half-lands would leave a reader on a
|
|
75
|
+
// truncated cursor. A rename within one folder is atomic.
|
|
76
|
+
async function writeAtomic(path: string, body: string): Promise<void> {
|
|
77
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
78
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
79
|
+
writeFileSync(temp, body)
|
|
80
|
+
renameSync(temp, path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function advanceCursor(
|
|
84
|
+
root: string,
|
|
85
|
+
input: AdvanceInput,
|
|
86
|
+
): Promise<AdvanceOutcome> {
|
|
87
|
+
if (!VERSION.test(input.version)) {
|
|
88
|
+
return { kind: 'refused', reason: 'invalid-version' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stored = readCursor(root)
|
|
92
|
+
if (stored && compareVersions(input.version, stored.version) < 0) {
|
|
93
|
+
return { kind: 'refused', reason: 'older-than-cursor' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.llms !== null) await writeAtomic(llmsPath(root), input.llms)
|
|
97
|
+
const next: Cursor = {
|
|
98
|
+
version: input.version,
|
|
99
|
+
date: input.today,
|
|
100
|
+
intake: input.intake,
|
|
101
|
+
}
|
|
102
|
+
await writeAtomic(cursorPath(root), `${JSON.stringify(next, null, 2)}\n`)
|
|
103
|
+
|
|
104
|
+
return { kind: 'advanced' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines = (text: string): string[] => text.split('\n').filter(Boolean)
|
|
108
|
+
|
|
109
|
+
export function llmsDifference(
|
|
110
|
+
last: string | null,
|
|
111
|
+
current: string,
|
|
112
|
+
): LlmsDifference {
|
|
113
|
+
const before = new Set(lines(last ?? ''))
|
|
114
|
+
const after = new Set(lines(current))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
added: [...after].filter((line) => !before.has(line)),
|
|
118
|
+
removed: [...before].filter((line) => !after.has(line)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractIdentifiers,
|
|
3
|
+
keepLines,
|
|
4
|
+
versionOf,
|
|
5
|
+
walkReleases,
|
|
6
|
+
type FetchFailure,
|
|
7
|
+
type PageFetcher,
|
|
8
|
+
type ReleaseLine,
|
|
9
|
+
} from '@/upstream/releases'
|
|
10
|
+
|
|
11
|
+
const FILE_HINTS = 3
|
|
12
|
+
|
|
13
|
+
export interface NamedIdentifier {
|
|
14
|
+
readonly name: string
|
|
15
|
+
readonly files: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DigestLine extends ReleaseLine {
|
|
19
|
+
readonly identifiers: NamedIdentifier[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Digest =
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'digest'
|
|
25
|
+
readonly from: string
|
|
26
|
+
readonly to: string
|
|
27
|
+
readonly releases: number
|
|
28
|
+
readonly lines: DigestLine[]
|
|
29
|
+
}
|
|
30
|
+
| { readonly kind: 'cursor-missing'; readonly cursor: string }
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: 'failed'
|
|
33
|
+
readonly reason: FetchFailure
|
|
34
|
+
readonly message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Files naming an identifier, supplied so a test needs no repository. */
|
|
38
|
+
export type Grep = (identifier: string) => Promise<string[]>
|
|
39
|
+
|
|
40
|
+
export async function buildDigest(
|
|
41
|
+
cursor: string,
|
|
42
|
+
fetchPage: PageFetcher,
|
|
43
|
+
grep: Grep,
|
|
44
|
+
): Promise<Digest> {
|
|
45
|
+
const walk = await walkReleases(cursor, fetchPage)
|
|
46
|
+
if (walk.kind !== 'reached') return walk
|
|
47
|
+
|
|
48
|
+
const oldestFirst = [...walk.releases].reverse()
|
|
49
|
+
const lines = await Promise.all(
|
|
50
|
+
oldestFirst.flatMap(keepLines).map(async (line) => ({
|
|
51
|
+
...line,
|
|
52
|
+
identifiers: await hintsFor(line.text, grep),
|
|
53
|
+
})),
|
|
54
|
+
)
|
|
55
|
+
const newest = walk.releases[0]
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind: 'digest',
|
|
59
|
+
from: cursor,
|
|
60
|
+
to: newest ? versionOf(newest.tag_name) : cursor,
|
|
61
|
+
releases: walk.releases.length,
|
|
62
|
+
lines,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function hintsFor(text: string, grep: Grep): Promise<NamedIdentifier[]> {
|
|
67
|
+
const found = await Promise.all(
|
|
68
|
+
extractIdentifiers(text).map(async (name) => ({
|
|
69
|
+
name,
|
|
70
|
+
files: (await grep(name)).slice(0, FILE_HINTS),
|
|
71
|
+
})),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return found.filter((entry) => entry.files.length > 0)
|
|
75
|
+
}
|