@erclx/canon 4.126.0 → 5.1.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 +5 -7
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/verb-absent.md +0 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +2 -2
- package/claude/skills/canon-operator/REQUIREMENT.md +2 -2
- package/claude/skills/canon-operator/SKILL.md +3 -3
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -0
- package/claude/skills/codebase-layout/SKILL.md +6 -0
- package/claude/skills/create-rule/REQUIREMENT.md +1 -1
- package/claude/skills/create-standard/REQUIREMENT.md +0 -2
- package/claude/skills/decision-escalate/REQUIREMENT.md +1 -2
- package/claude/skills/draft-context/REQUIREMENT.md +1 -1
- package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/git-pr/references/task-number.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +1 -1
- package/claude/skills/target-setup/REQUIREMENT.md +2 -2
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +1 -1
- package/claude/skills/target-setup/references/indexes.md +1 -5
- package/claude/skills/task-board/references/archive.md +1 -0
- package/docs/agents/commands.md +1 -3
- package/docs/agents/counts.md +1 -2
- package/docs/agents/docs.md +1 -1
- package/docs/agents/drift-surfaces.md +6 -4
- package/docs/agents/index.md +1 -1
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +1 -8
- package/docs/agents/markdown-audit.md +2 -2
- package/docs/agents/restated.md +7 -5
- package/docs/agents/review-classification.md +1 -2
- package/docs/agents/scripting.md +2 -3
- package/docs/agents/skills-reach.md +3 -3
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks-archive.md +1 -1
- package/docs/agents/tasks.md +4 -0
- package/docs/target/index.md +4 -4
- package/docs/target/{target-migrations.md → migrations.md} +17 -1
- package/docs/target/{target-projects.md → projects.md} +5 -7
- package/docs/target/{target-scenarios.md → scenarios.md} +1 -1
- package/docs/target/{target-sync.md → sync.md} +4 -2
- package/docs/workflow/ai-workflow.md +3 -7
- package/docs/workflow/operating-model.md +4 -4
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -2
- package/docs/workflow/visual-design-workflow.md +5 -5
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -2
- package/scripts/core/check-plugin-boundary.sh +1 -1
- package/scripts/core/install-check.sh +3 -4
- package/scripts/core/regen-hero.sh +2 -9
- package/src/autoship/paths.ts +3 -5
- package/src/claude/cases/authoring.ts +1 -5
- package/src/claude/skills-reach.ts +1 -2
- package/src/cli.ts +0 -2
- package/src/commands/feedback-format.ts +0 -1
- package/src/commands/gov.ts +8 -7
- package/src/commands/labels.ts +3 -3
- package/src/commands/markdown.ts +2 -2
- package/src/commands/tasks.ts +82 -2
- package/src/counts/catalogs.ts +0 -16
- package/src/counts/scan.ts +1 -1
- package/src/docs/read.ts +25 -25
- package/src/gate/measures.ts +52 -0
- package/src/gate/stages.ts +9 -0
- package/src/gov/restated.ts +60 -22
- package/src/help.ts +0 -2
- package/src/init/steps.ts +1 -1
- package/src/labels/format.ts +2 -2
- package/src/migrate/skill-names.ts +4 -5
- package/src/secrets/shipped.ts +2 -2
- package/src/serve/static.ts +1 -2
- package/src/shipped/references.ts +7 -8
- package/src/sync/engine.ts +2 -2
- package/src/tasks/archive.ts +30 -0
- package/src/tasks/record.ts +106 -9
- package/standards/commit.md +1 -1
- package/standards/context.md +2 -0
- package/standards/docs.md +1 -0
- package/standards/index.md +0 -1
- package/standards/pr.md +1 -1
- package/standards/skill.md +1 -2
- package/standards/tasks.md +2 -2
- package/standards/wiki.md +2 -1
- package/tooling/base/reference.md +2 -2
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/create-snippet/REQUIREMENT.md +0 -39
- package/claude/skills/create-snippet/SKILL.md +0 -30
- package/scripts/snippets/create.sh +0 -156
- package/snippets/align.md +0 -12
- package/snippets/claude/decision-memo.md +0 -39
- package/snippets/claude/feature-recap.md +0 -19
- package/snippets/claude/figma-steps.md +0 -24
- package/snippets/compact-summary.md +0 -5
- package/snippets/decision-help.md +0 -6
- package/snippets/meta-prompt.md +0 -14
- package/snippets/research-prompt.md +0 -7
- package/snippets/session-notes.md +0 -11
- package/snippets/snippets.toml +0 -5
- package/snippets/step-by-step.md +0 -10
- package/snippets/web-research.md +0 -21
- package/src/commands/snippets.ts +0 -90
- package/src/snippets/categories.ts +0 -66
- package/src/snippets/list.ts +0 -32
- package/src/snippets/presets.ts +0 -50
- package/standards/snippets.md +0 -76
package/src/commands/tasks.ts
CHANGED
|
@@ -64,6 +64,8 @@ const EXIT_FINDINGS = 2
|
|
|
64
64
|
/** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
|
|
65
65
|
const GIT_TIMEOUT_MS = 10_000
|
|
66
66
|
|
|
67
|
+
const TRUNK_BRANCHES: readonly string[] = ['main', 'master']
|
|
68
|
+
|
|
67
69
|
interface ArchiveCommandOptions {
|
|
68
70
|
readonly json?: boolean
|
|
69
71
|
readonly pullRequest?: string
|
|
@@ -164,6 +166,10 @@ export function register(program: Command): void {
|
|
|
164
166
|
'folder that does not resolve, or a destination already taken, moves',
|
|
165
167
|
'nothing and refuses nothing.',
|
|
166
168
|
'',
|
|
169
|
+
'With --pull-request, it refuses earlier-slice when a later number is',
|
|
170
|
+
'listed, and pending-branch while a Pending branch: line names a branch',
|
|
171
|
+
'whose own pull request is not recorded yet. A stem skips both.',
|
|
172
|
+
'',
|
|
167
173
|
'Exit codes:',
|
|
168
174
|
' 0 the task was archived',
|
|
169
175
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
@@ -420,6 +426,10 @@ export function register(program: Command): void {
|
|
|
420
426
|
'list is replaced. The board is shared scratch, so a linked worktree',
|
|
421
427
|
'records against the same board.',
|
|
422
428
|
'',
|
|
429
|
+
'It also drops the current branch, and each name it was renamed from,',
|
|
430
|
+
'from the Pending branch: line, even when the number was already',
|
|
431
|
+
'listed, and removes the line once empty.',
|
|
432
|
+
'',
|
|
423
433
|
'Examples:',
|
|
424
434
|
' canon tasks pull-request 673 v28.1-trigger-escalation',
|
|
425
435
|
' canon tasks pull-request 673 --plan worktree-scratch-routing --json',
|
|
@@ -497,6 +507,11 @@ export function register(program: Command): void {
|
|
|
497
507
|
'An outcome already closed is reported rather than refused, so a rerun',
|
|
498
508
|
'against the same positions is safe.',
|
|
499
509
|
'',
|
|
510
|
+
'Run from a branch other than main or master, a close that ticks a new',
|
|
511
|
+
'box names that branch on a Pending branch: line, which holds the task',
|
|
512
|
+
'against a merge-time archive until canon tasks pull-request records',
|
|
513
|
+
"the branch's own number.",
|
|
514
|
+
'',
|
|
500
515
|
'Examples:',
|
|
501
516
|
' canon tasks outcome v28.1-trigger-escalation --close 1 --close 3',
|
|
502
517
|
' canon tasks outcome --plan worktree-scratch-routing --close 2 --json',
|
|
@@ -644,11 +659,63 @@ async function runPullRequest(
|
|
|
644
659
|
}
|
|
645
660
|
|
|
646
661
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
647
|
-
const
|
|
662
|
+
const branch = await workingBranch()
|
|
663
|
+
const outcome = await recordPullRequest(
|
|
664
|
+
root,
|
|
665
|
+
selector,
|
|
666
|
+
Number(number),
|
|
667
|
+
branch ? [branch, ...(await formerNames(branch))] : [],
|
|
668
|
+
)
|
|
648
669
|
|
|
649
670
|
return reportPullRequest(outcome, emitJson, root)
|
|
650
671
|
}
|
|
651
672
|
|
|
673
|
+
const RENAME_PATTERN = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\//
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Names the branch carried before each `git branch -m`, read off its reflog,
|
|
677
|
+
* which moves with a rename and records every earlier one. A marker written
|
|
678
|
+
* under an old name is otherwise one nothing ever clears.
|
|
679
|
+
*/
|
|
680
|
+
async function formerNames(branch: string): Promise<readonly string[]> {
|
|
681
|
+
const result = await execa(
|
|
682
|
+
'git',
|
|
683
|
+
[
|
|
684
|
+
'-C',
|
|
685
|
+
process.cwd(),
|
|
686
|
+
'reflog',
|
|
687
|
+
'show',
|
|
688
|
+
'--format=%gs',
|
|
689
|
+
`refs/heads/${branch}`,
|
|
690
|
+
],
|
|
691
|
+
{ reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
|
|
692
|
+
)
|
|
693
|
+
if (result.exitCode !== 0) return []
|
|
694
|
+
|
|
695
|
+
return result.stdout
|
|
696
|
+
.split('\n')
|
|
697
|
+
.map((line) => RENAME_PATTERN.exec(line)?.[1])
|
|
698
|
+
.filter((name): name is string => name !== undefined)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The branch checked out where the command runs, which is where the work is,
|
|
703
|
+
* rather than the board root's. Undefined on trunk, a detached HEAD, or a
|
|
704
|
+
* failed read, where no slice is in flight to hold the task open.
|
|
705
|
+
*/
|
|
706
|
+
async function workingBranch(): Promise<string | undefined> {
|
|
707
|
+
const result = await execa(
|
|
708
|
+
'git',
|
|
709
|
+
['-C', process.cwd(), 'branch', '--show-current'],
|
|
710
|
+
{ reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
const branch = result.exitCode === 0 ? result.stdout.trim() : ''
|
|
714
|
+
if (branch.length === 0 || TRUNK_BRANCHES.includes(branch)) return undefined
|
|
715
|
+
|
|
716
|
+
return branch
|
|
717
|
+
}
|
|
718
|
+
|
|
652
719
|
async function runPlanLink(
|
|
653
720
|
task: string,
|
|
654
721
|
plan: string,
|
|
@@ -710,7 +777,12 @@ async function runOutcome(
|
|
|
710
777
|
}
|
|
711
778
|
|
|
712
779
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
713
|
-
const outcome = await closeOutcomes(
|
|
780
|
+
const outcome = await closeOutcomes(
|
|
781
|
+
root,
|
|
782
|
+
selector,
|
|
783
|
+
raw.map(Number),
|
|
784
|
+
await workingBranch(),
|
|
785
|
+
)
|
|
714
786
|
|
|
715
787
|
return reportOutcome(outcome, emitJson, root)
|
|
716
788
|
}
|
|
@@ -765,6 +837,7 @@ function reportPullRequest(
|
|
|
765
837
|
path: relative(root, outcome.path),
|
|
766
838
|
pullRequest: outcome.number,
|
|
767
839
|
action: outcome.action,
|
|
840
|
+
pending: outcome.pending,
|
|
768
841
|
})}\n`,
|
|
769
842
|
)
|
|
770
843
|
return 0
|
|
@@ -773,6 +846,9 @@ function reportPullRequest(
|
|
|
773
846
|
intro('canon tasks pull-request')
|
|
774
847
|
logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
|
|
775
848
|
logInfo(`${outcome.stem} names pull request #${outcome.number}`)
|
|
849
|
+
if (outcome.pending.length > 0) {
|
|
850
|
+
logInfo(`still pending on ${outcome.pending.join(', ')}`)
|
|
851
|
+
}
|
|
776
852
|
if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
|
|
777
853
|
outro()
|
|
778
854
|
|
|
@@ -831,6 +907,7 @@ function reportOutcome(
|
|
|
831
907
|
path: relative(root, outcome.path),
|
|
832
908
|
closed: outcome.closed,
|
|
833
909
|
alreadyClosed: outcome.alreadyClosed,
|
|
910
|
+
pending: outcome.pending,
|
|
834
911
|
})}\n`,
|
|
835
912
|
)
|
|
836
913
|
return 0
|
|
@@ -841,6 +918,9 @@ function reportOutcome(
|
|
|
841
918
|
|
|
842
919
|
for (const closed of outcome.closed) logAdd(closed)
|
|
843
920
|
for (const already of outcome.alreadyClosed) logInfo(`${already} (already)`)
|
|
921
|
+
if (outcome.pending.length > 0) {
|
|
922
|
+
logInfo(`pending on ${outcome.pending.join(', ')}`)
|
|
923
|
+
}
|
|
844
924
|
|
|
845
925
|
if (outcome.closed.length > 0) logInfo(relative(root, outcome.path))
|
|
846
926
|
outro()
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { AUDITS } from '@/audits/catalog'
|
|
4
4
|
import { listSkills } from '@/claude/skills-list'
|
|
5
5
|
import { buildGovCatalog } from '@/gov/list'
|
|
6
|
-
import { buildSnippetsCatalog } from '@/snippets/list'
|
|
7
6
|
import { listStandards } from '@/standards/read'
|
|
8
7
|
|
|
9
8
|
export interface Catalog {
|
|
@@ -39,16 +38,6 @@ function countCommands(root: string): number | undefined {
|
|
|
39
38
|
return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
/**
|
|
43
|
-
* The distinct entries across every category, matching what `regen-hero.sh`
|
|
44
|
-
* counts. A snippet reachable from two categories is one snippet, not two.
|
|
45
|
-
*/
|
|
46
|
-
function countSnippets(root: string): number {
|
|
47
|
-
const catalog = buildSnippetsCatalog(root)
|
|
48
|
-
return new Set(catalog.categories.flatMap((category) => category.entries))
|
|
49
|
-
.size
|
|
50
|
-
}
|
|
51
|
-
|
|
52
41
|
/**
|
|
53
42
|
* The closed set of catalogs this sweep counts a document against.
|
|
54
43
|
*
|
|
@@ -75,11 +64,6 @@ export const CATALOGS: readonly Catalog[] = [
|
|
|
75
64
|
nouns: ['standard', 'standards'],
|
|
76
65
|
count: (root) => listStandards(root).length,
|
|
77
66
|
},
|
|
78
|
-
{
|
|
79
|
-
id: 'snippets',
|
|
80
|
-
nouns: ['snippet', 'snippets'],
|
|
81
|
-
count: countSnippets,
|
|
82
|
-
},
|
|
83
67
|
{
|
|
84
68
|
id: 'commands',
|
|
85
69
|
nouns: ['command', 'commands'],
|
package/src/counts/scan.ts
CHANGED
|
@@ -153,7 +153,7 @@ const ARTICLES = ['the', 'a', 'an']
|
|
|
153
153
|
* either number.
|
|
154
154
|
*
|
|
155
155
|
* The optional word between the number and the noun is what reaches a form
|
|
156
|
-
* like `installs 11 shipped
|
|
156
|
+
* like `installs 11 shipped rules` without also reaching past an
|
|
157
157
|
* intervening clause, since a wider gap would start pairing a number in one
|
|
158
158
|
* clause with a noun in the next. Requiring the noun's own plural form to
|
|
159
159
|
* double as its match narrows the corpus this reads without hand-listing
|
package/src/docs/read.ts
CHANGED
|
@@ -56,22 +56,22 @@ export function resolveTopic(
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Names every sub-area file called `<topic>.md
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* Names every sub-area file called `<topic>.md` in the first root whose folders
|
|
60
|
+
* carry one, so the root precedence above holds one folder down as well. A name
|
|
61
|
+
* carried by more than one folder of that root resolves to none of them, since
|
|
62
|
+
* answering a bare `overview` with whichever folder sorts first is a confident
|
|
63
|
+
* wrong answer where the miss is a listing of what the caller could have typed.
|
|
63
64
|
*/
|
|
64
65
|
function findLeaves(root: string, topic: string): string[] {
|
|
65
|
-
const matches: string[] = []
|
|
66
|
-
|
|
67
66
|
for (const dir of ROOTS) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const matches = catalogedFolders(join(root, dir))
|
|
68
|
+
.map((folder) => join(dir, folder, `${topic}.md`))
|
|
69
|
+
.filter((rel) => existsSync(join(root, rel)))
|
|
70
|
+
|
|
71
|
+
if (matches.length > 0) return matches
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
return
|
|
74
|
+
return []
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
@@ -100,20 +100,20 @@ export function listTopics(root: string): string[] {
|
|
|
100
100
|
const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
|
|
101
101
|
|
|
102
102
|
const taken = new Set(named.flat())
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
].sort()
|
|
116
|
-
)
|
|
103
|
+
const claimed = new Set<string>()
|
|
104
|
+
|
|
105
|
+
return ROOTS.flatMap((_, position) => {
|
|
106
|
+
const here = leaves[position] as string[]
|
|
107
|
+
const reachable = [...new Set(here)].filter(
|
|
108
|
+
(name) =>
|
|
109
|
+
!taken.has(name) &&
|
|
110
|
+
!claimed.has(name) &&
|
|
111
|
+
here.indexOf(name) === here.lastIndexOf(name),
|
|
112
|
+
)
|
|
113
|
+
for (const name of here) claimed.add(name)
|
|
114
|
+
|
|
115
|
+
return [...(named[position] as string[]), ...reachable].sort()
|
|
116
|
+
})
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/** The sibling files and split folders of one root, which shadow every leaf. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -361,6 +361,58 @@ export const documentCeiling: Measure = async (ctx) => {
|
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Whether a path's stem, its basename up to the first `.`, repeats its
|
|
366
|
+
* immediate parent folder, either whole or as a `<folder>-` prefix.
|
|
367
|
+
*
|
|
368
|
+
* Exact rather than singular-aware, so `standards/standard.md` passes. A false
|
|
369
|
+
* positive on a gate stage teaches a contributor to route around it, and a
|
|
370
|
+
* widened match is cheap to add once a real echo slips through.
|
|
371
|
+
*/
|
|
372
|
+
export function isFolderEchoedName(path: string): boolean {
|
|
373
|
+
const segments = path.split('/')
|
|
374
|
+
if (segments.length < 2) return false
|
|
375
|
+
const folder = segments[segments.length - 2] ?? ''
|
|
376
|
+
const stem = (segments[segments.length - 1] ?? '').split('.')[0] ?? ''
|
|
377
|
+
if (folder === '' || stem === '') return false
|
|
378
|
+
return stem === folder || stem.startsWith(`${folder}-`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Every tracked file whose name repeats the folder it sits in, which says the
|
|
383
|
+
* folder's name twice in every path that cites it.
|
|
384
|
+
*
|
|
385
|
+
* Reads paths only, so it runs over the whole tree rather than the markdown
|
|
386
|
+
* corpus. It stays out of `canon markdown audit`, which runs inside every
|
|
387
|
+
* target, where a folder-named file may be a convention the target chose.
|
|
388
|
+
*/
|
|
389
|
+
export const folderEchoedNames: Measure = async (ctx) => {
|
|
390
|
+
const files = await listRepositoryFiles(ctx.root)
|
|
391
|
+
if (files === undefined) {
|
|
392
|
+
return {
|
|
393
|
+
emissions: [],
|
|
394
|
+
unmeasured:
|
|
395
|
+
'The tracked file list could not be read, so no filename was checked.',
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const echoed = files.filter(isFolderEchoedName)
|
|
400
|
+
if (echoed.length === 0) {
|
|
401
|
+
return {
|
|
402
|
+
emissions: [
|
|
403
|
+
info(
|
|
404
|
+
`No filename repeats its folder across ${plural(files.length, 'tracked file')}`,
|
|
405
|
+
),
|
|
406
|
+
],
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
emissions: echoed.map((path) => warn(`${path} repeats its folder's name`)),
|
|
412
|
+
failure: `${echoed.length === 1 ? 'One tracked file repeats its' : `${echoed.length} tracked files repeat their`} folder's name: ${echoed.join(', ')}. Rename each for what it covers inside the folder, dropping the prefix the folder already states.`,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
364
416
|
/**
|
|
365
417
|
* A second run of the records move should rewrite nothing, and the count is
|
|
366
418
|
* only knowable once the folders themselves have landed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
captureStamps,
|
|
5
5
|
clientCommandCitations,
|
|
6
6
|
documentCeiling,
|
|
7
|
+
folderEchoedNames,
|
|
7
8
|
markdownBans,
|
|
8
9
|
type Measure,
|
|
9
10
|
pluginManifests,
|
|
@@ -366,6 +367,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
366
367
|
label: 'Document ceiling',
|
|
367
368
|
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
368
369
|
},
|
|
370
|
+
{
|
|
371
|
+
// Unscoped, since any branch can add a file anywhere and one git ls-files
|
|
372
|
+
// costs nothing. Reads paths rather than markdown, so it sits here rather
|
|
373
|
+
// than in the markdown audit that also runs inside every target.
|
|
374
|
+
id: 'folder-echoed-names',
|
|
375
|
+
label: 'Folder-echoed filenames',
|
|
376
|
+
checks: [{ kind: 'measure', measure: folderEchoedNames }],
|
|
377
|
+
},
|
|
369
378
|
{
|
|
370
379
|
// A rule citing a file that moved fails silently. The consumed-copy drift
|
|
371
380
|
// stage passes an authored rule and its copy that are wrong together, and
|
package/src/gov/restated.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { RULE_DIRS } from '@/gov/citations'
|
|
3
4
|
|
|
4
5
|
/** The always-loaded file whose bullets are the subjects this sweep matches. */
|
|
5
6
|
export const INSTRUCTIONS_REL = 'CLAUDE.md'
|
|
@@ -11,15 +12,26 @@ export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
|
|
|
11
12
|
export const SHIPPED_SKILLS_REL = join('claude', 'skills')
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
* The project's own skill bodies. Here that is the toolkit's internal skills,
|
|
16
|
+
* and in a target it is that project's own, which is equally a place a rule
|
|
17
|
+
* gets restated in prose.
|
|
18
|
+
*/
|
|
19
|
+
export const PROJECT_SKILLS_REL = join('.claude', 'skills')
|
|
20
|
+
|
|
21
|
+
/** Every root a candidate body is read from, each reported under its own path. */
|
|
22
|
+
const SKILL_ROOTS: readonly string[] = [SHIPPED_SKILLS_REL, PROJECT_SKILLS_REL]
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Path-scoped rules are read from every root in `RULE_DIRS`, the authoring
|
|
26
|
+
* roots rather than the consumed copy under `.claude/rules/`. A stack takes a
|
|
27
|
+
* whole rule folder, so a bullet landing anywhere under `governance/rules/`
|
|
28
|
+
* ships to every target the stack reaches, the same way a bullet in the
|
|
29
|
+
* always-loaded file or the seed does, and `internal/rules/` loads the same
|
|
30
|
+
* way for every session in this repository. That is what makes a rule an
|
|
31
|
+
* instruction surface rather than a place a rule is merely quoted, and it is
|
|
32
|
+
* why a bullet moved into either root from the seed still belongs to the
|
|
33
|
+
* corpus this sweep reads rather than leaving it.
|
|
21
34
|
*/
|
|
22
|
-
export const RULES_REL = join('governance', 'rules')
|
|
23
35
|
|
|
24
36
|
/**
|
|
25
37
|
* Path pairs whose duplication is deliberate and already recorded.
|
|
@@ -174,6 +186,22 @@ const STOPWORDS = new Set([
|
|
|
174
186
|
*/
|
|
175
187
|
const PROHIBITIONS = ['never', 'do not', "don't", 'avoid', 'refuse']
|
|
176
188
|
|
|
189
|
+
/**
|
|
190
|
+
* A `no` opening a clause or a comma-set phrase directly on a code span, as in
|
|
191
|
+
* `pinned to major tags, no `@latest``.
|
|
192
|
+
*
|
|
193
|
+
* `no` is admitted here and only here, since sitting on a backticked
|
|
194
|
+
* identifier it names the thing ruled out rather than qualifying a verb. The
|
|
195
|
+
* phrase boundary is what keeps description out: `returns no `jq` output` puts
|
|
196
|
+
* the marker after a verb and reports what happens.
|
|
197
|
+
*
|
|
198
|
+
* `not` and `never` stay out. Admitting them turned two agreeing pairs into
|
|
199
|
+
* contradictions, `, not `pwd`` and `, never `@latest``, because the subject
|
|
200
|
+
* each matched spelled its own negation on plain words or in a clause the
|
|
201
|
+
* anchors tied against, so the reading flipped on one side alone.
|
|
202
|
+
*/
|
|
203
|
+
const NEGATED_SPAN = /(?:^|[,;:(])\s*no\s+`/
|
|
204
|
+
|
|
177
205
|
export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
|
|
178
206
|
|
|
179
207
|
export type Restatement = 'mirror' | 'repetition' | 'contradiction'
|
|
@@ -315,7 +343,10 @@ function prohibits(text: string): boolean {
|
|
|
315
343
|
.replace(/^[^a-z]*/, '')
|
|
316
344
|
.replace(/^(and|but|so|then|also|however)[\s,]+/, '')
|
|
317
345
|
|
|
318
|
-
return
|
|
346
|
+
return (
|
|
347
|
+
PROHIBITIONS.some((marker) => opening.startsWith(marker)) ||
|
|
348
|
+
NEGATED_SPAN.test(opening)
|
|
349
|
+
)
|
|
319
350
|
}
|
|
320
351
|
|
|
321
352
|
/**
|
|
@@ -465,7 +496,10 @@ function readBullets(root: string, relative: string): Statement[] {
|
|
|
465
496
|
* is reused per file rather than rebuilt for prose, and frontmatter is read
|
|
466
497
|
* past for the same reason it is: no line there starts with `- `.
|
|
467
498
|
*/
|
|
468
|
-
function readRuleFiles(root: string,
|
|
499
|
+
function readRuleFiles(root: string, rulesRel: string): Statement[] {
|
|
500
|
+
const rulesRoot = join(root, rulesRel)
|
|
501
|
+
if (!existsSync(rulesRoot)) return []
|
|
502
|
+
|
|
469
503
|
const files = [
|
|
470
504
|
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
471
505
|
].sort()
|
|
@@ -473,13 +507,13 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
|
|
|
473
507
|
return files.flatMap((file) =>
|
|
474
508
|
readBullets(
|
|
475
509
|
root,
|
|
476
|
-
`${
|
|
510
|
+
`${rulesRel.replaceAll('\\', '/')}/${file.replaceAll('\\', '/')}`,
|
|
477
511
|
),
|
|
478
512
|
)
|
|
479
513
|
}
|
|
480
514
|
|
|
481
515
|
/**
|
|
482
|
-
* Every prose line and bullet in a
|
|
516
|
+
* Every prose line and bullet in a skill body under one skills root.
|
|
483
517
|
*
|
|
484
518
|
* Wider than the bullet rule above because the motivating case was stated in a
|
|
485
519
|
* body as a paragraph, so a bullet-only read would miss the one instance this
|
|
@@ -487,7 +521,10 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
|
|
|
487
521
|
* heading names a section rather than stating a rule, and a fenced block is an
|
|
488
522
|
* example whose words are the surrounding prose's by construction.
|
|
489
523
|
*/
|
|
490
|
-
function readBodyLines(root: string,
|
|
524
|
+
function readBodyLines(root: string, skillsRel: string): Candidate[] {
|
|
525
|
+
const skillsRoot = join(root, skillsRel)
|
|
526
|
+
if (!existsSync(skillsRoot)) return []
|
|
527
|
+
|
|
491
528
|
const candidates: Candidate[] = []
|
|
492
529
|
|
|
493
530
|
const files = [
|
|
@@ -500,7 +537,7 @@ function readBodyLines(root: string, skillsRoot: string): Candidate[] {
|
|
|
500
537
|
for (const file of files) {
|
|
501
538
|
const posix = file.replaceAll('\\', '/')
|
|
502
539
|
const skill = posix.split('/')[0]
|
|
503
|
-
const relative = `${
|
|
540
|
+
const relative = `${skillsRel.replaceAll('\\', '/')}/${posix}`
|
|
504
541
|
|
|
505
542
|
const lines = readFileSync(join(root, relative), 'utf8').split('\n')
|
|
506
543
|
let fenced = false
|
|
@@ -639,8 +676,9 @@ function classify(
|
|
|
639
676
|
* defect the sweep exists for, and a recall-first reading can be narrowed from
|
|
640
677
|
* real output where the reverse cannot.
|
|
641
678
|
*
|
|
642
|
-
* The always-loaded file and every rule
|
|
643
|
-
*
|
|
679
|
+
* The always-loaded file and every rule under either rule root each open the
|
|
680
|
+
* search as a subject, and the seed, the skill bodies under both skill roots,
|
|
681
|
+
* and every rule again close it as a candidate.
|
|
644
682
|
* A rule sits on both sides because a stack ships a whole rule folder, so a
|
|
645
683
|
* bullet duplicated between two rules reaches a target exactly as a bullet
|
|
646
684
|
* duplicated between the always-loaded file and a rule does, and neither shape
|
|
@@ -660,14 +698,14 @@ export function readRestated(root: string): RestatedReport {
|
|
|
660
698
|
kind: 'seed' as const,
|
|
661
699
|
}))
|
|
662
700
|
|
|
663
|
-
const
|
|
664
|
-
|
|
701
|
+
const bodies = SKILL_ROOTS.flatMap((skillsRel) =>
|
|
702
|
+
readBodyLines(root, skillsRel),
|
|
703
|
+
)
|
|
665
704
|
const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
|
|
666
705
|
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
: []
|
|
706
|
+
const ruleStatements = RULE_DIRS.flatMap((rulesRel) =>
|
|
707
|
+
readRuleFiles(root, rulesRel),
|
|
708
|
+
)
|
|
671
709
|
const rules: Candidate[] = ruleStatements.map((statement) => ({
|
|
672
710
|
...statement,
|
|
673
711
|
kind: 'rule' as const,
|
package/src/help.ts
CHANGED
|
@@ -26,7 +26,6 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
26
26
|
rows: [
|
|
27
27
|
['gov [cmd]', 'Governance rules (install, sync, list)'],
|
|
28
28
|
['standards [cmd]', 'Standards (install, sync, list, <name>)'],
|
|
29
|
-
['snippets [cmd]', 'Prompt snippets (create, list)'],
|
|
30
29
|
['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
|
|
31
30
|
['claude [cmd]', 'Claude workflow (init, sync, setup)'],
|
|
32
31
|
['wiki [cmd]', 'Wiki pages (init)'],
|
|
@@ -93,7 +92,6 @@ const EXAMPLES = [
|
|
|
93
92
|
'canon gov sync ../my-app',
|
|
94
93
|
'canon gov restated --json',
|
|
95
94
|
'canon standards markdown',
|
|
96
|
-
'canon snippets list',
|
|
97
95
|
'canon init ../my-app',
|
|
98
96
|
'canon tooling sync base',
|
|
99
97
|
'canon tooling create',
|
package/src/init/steps.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { DomainStep } from '@/init/run'
|
|
|
5
5
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* States the same fact as `docs/target/
|
|
8
|
+
* States the same fact as `docs/target/migrations.md`'s backup section in its
|
|
9
9
|
* own words. Keep the two in step by hand; nothing compares them.
|
|
10
10
|
*/
|
|
11
11
|
const RECORDS_SETUP_NOTICE =
|
package/src/labels/format.ts
CHANGED
|
@@ -22,8 +22,8 @@ const TITLE_MAX_LENGTH = 72
|
|
|
22
22
|
|
|
23
23
|
// Casing-agnostic on purpose. Casing is graded separately once the shape
|
|
24
24
|
// matches, so `type` and `scope` accept either case here and `structure`
|
|
25
|
-
// reports only a title with no `<type>(<scope>): <subject>` shape at all.
|
|
26
|
-
const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\)
|
|
25
|
+
// reports only a title with no `<type>(<scope>)[!]: <subject>` shape at all.
|
|
26
|
+
const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\)!?: (.+)$/
|
|
27
27
|
const LEADING_LETTERS = /^[A-Za-z]+/
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -93,11 +93,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
93
93
|
* one destination, so the ordering is asserted directly.
|
|
94
94
|
*
|
|
95
95
|
* It matches whole tokens. A skill name is a complete name rather than a word
|
|
96
|
-
* stem, and
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* would have moved to a name the folder's own convention contradicts.
|
|
96
|
+
* stem, and a word can open with a name and mean something else: a filename
|
|
97
|
+
* such as `claude-worktrees.md` names the harness feature, not the skill, and
|
|
98
|
+
* a stem matching by prefix would move it to a name that no longer describes
|
|
99
|
+
* what the file documents.
|
|
101
100
|
*
|
|
102
101
|
* No article fixup travels with it. `auto-ship` is the one destination opening
|
|
103
102
|
* on a vowel sound, and the corpus spells an article before it once, which is
|
package/src/secrets/shipped.ts
CHANGED
|
@@ -13,8 +13,8 @@ import { join } from 'node:path'
|
|
|
13
13
|
*
|
|
14
14
|
* What it does not cover is the plugin, which a marketplace install reads live
|
|
15
15
|
* from `claude/` rather than from a tarball. That folder is a `files` entry
|
|
16
|
-
* too, and its `standards`
|
|
17
|
-
*
|
|
16
|
+
* too, and its `standards` symlink resolves into a tree the field lists in its
|
|
17
|
+
* own right, so both routes land inside the same corpus.
|
|
18
18
|
*/
|
|
19
19
|
export type ShipEntries =
|
|
20
20
|
/** The field declares a corpus, which is what this check reads. */
|
package/src/serve/static.ts
CHANGED
|
@@ -186,8 +186,7 @@ export function resolveWithin(
|
|
|
186
186
|
* Re-tests containment after following symlinks. `resolveWithin` is lexical
|
|
187
187
|
* and `resolve` does not follow a link, so a link inside the root clears that
|
|
188
188
|
* test while the file it points at sits outside. This repository is a live
|
|
189
|
-
* instance, since `claude/standards`
|
|
190
|
-
* `claude/`.
|
|
189
|
+
* instance, since `claude/standards` is a link out of `claude/`.
|
|
191
190
|
*
|
|
192
191
|
* Only a path that exists is checked, because a link can be followed only once
|
|
193
192
|
* there is something on the other end, and a path that resolves to nothing is
|
|
@@ -10,8 +10,8 @@ import { isMarked } from '@/exempt-marker'
|
|
|
10
10
|
* on purpose rather than by omission. What decides membership here is whether
|
|
11
11
|
* anything serves the file to a reader who does not hold this repository: a
|
|
12
12
|
* skill body loads into a session, a docs page is read through `canon docs`, a
|
|
13
|
-
* standard through `canon standards`, a rule through a glob match, a
|
|
14
|
-
* through an
|
|
13
|
+
* standard through `canon standards`, a rule through a glob match, and a seed
|
|
14
|
+
* through an install. A `src/` doc comment
|
|
15
15
|
* is read by someone working on the toolkit, in this checkout, where every
|
|
16
16
|
* number resolves.
|
|
17
17
|
*
|
|
@@ -20,17 +20,16 @@ import { isMarked } from '@/exempt-marker'
|
|
|
20
20
|
* `#191512`, which no width or boundary rule separates from a pull request
|
|
21
21
|
* number, so telling them apart would need the line's syntactic position.
|
|
22
22
|
*
|
|
23
|
-
* `claude/standards`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* `claude/standards` is a symlink into one of the roots below, and a walk that
|
|
24
|
+
* followed it would read that corpus twice and report every finding in it
|
|
25
|
+
* under two paths. Nothing is lost by not following, since it is a corpus here
|
|
26
|
+
* in its own right.
|
|
27
27
|
*/
|
|
28
28
|
export const SHIPPED_CORPORA = [
|
|
29
29
|
'claude',
|
|
30
30
|
'docs',
|
|
31
31
|
'governance',
|
|
32
32
|
'scripts',
|
|
33
|
-
'snippets',
|
|
34
33
|
'standards',
|
|
35
34
|
'tooling',
|
|
36
35
|
] as const
|
|
@@ -142,7 +141,7 @@ const SAME_REPOSITORY =
|
|
|
142
141
|
* `canon/context/` carries no equivalent pattern. The same resolution test
|
|
143
142
|
* cannot separate a path every scaffolded project holds, such as
|
|
144
143
|
* `canon/context/index.md`, from this repository's own domain entry, such
|
|
145
|
-
* as `canon/context/indexes.md`, since both resolve here and only the
|
|
144
|
+
* as `canon/context/context-model/indexes.md`, since both resolve here and only the
|
|
146
145
|
* second is a defect. That is a semantic read no pattern makes, so it stays
|
|
147
146
|
* with the rule and the review checklist rather than joining this reader.
|
|
148
147
|
*/
|
package/src/sync/engine.ts
CHANGED
|
@@ -129,11 +129,11 @@ export type NonInteractivePolicy =
|
|
|
129
129
|
/**
|
|
130
130
|
* The two holes every domain sync leaves open: where a destination file's
|
|
131
131
|
* source lives, and what counts as a change beyond a plain content diff.
|
|
132
|
-
* Everything else in a sync is identical across
|
|
132
|
+
* Everything else in a sync is identical across domains,
|
|
133
133
|
* so it lives in the engine.
|
|
134
134
|
*
|
|
135
135
|
* The optional members exist because one adapter needed each of them. Leaving
|
|
136
|
-
* all three unset reproduces the behavior gov
|
|
136
|
+
* all three unset reproduces the behavior gov already had.
|
|
137
137
|
*/
|
|
138
138
|
export interface SyncAdapter {
|
|
139
139
|
readonly banner: string
|