@erclx/canon 4.117.0 → 4.118.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/canon-operator/SKILL.md +1 -1
- package/claude/skills/draft-ready/references/assembly.md +8 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +7 -0
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +3 -1
- package/claude/skills/target-check/references/domains.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit-checks.md +2 -28
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +23 -17
- package/docs/target-projects.md +2 -2
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/src/commands/context.ts +11 -111
- package/src/commands/sync.ts +2 -2
- package/src/context/audit.ts +3 -145
- package/src/gate/measures.ts +37 -0
- package/src/gate/stages.ts +9 -0
- package/src/gov/adapter.ts +8 -6
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +7 -1
- package/src/sync/engine.ts +90 -10
- package/src/sync/stamp.ts +44 -0
- package/standards/plan.md +24 -1
- package/src/context/narration.ts +0 -99
package/src/commands/context.ts
CHANGED
|
@@ -56,12 +56,6 @@ import {
|
|
|
56
56
|
} from '@/context/folders'
|
|
57
57
|
import { isGating } from '@/context/gate'
|
|
58
58
|
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
59
|
-
import {
|
|
60
|
-
loadNarration,
|
|
61
|
-
type Narration,
|
|
62
|
-
PRONOUN_HEADING,
|
|
63
|
-
VERB_HEADING,
|
|
64
|
-
} from '@/context/narration'
|
|
65
59
|
import {
|
|
66
60
|
measureWireframeFolder,
|
|
67
61
|
type WireframeStatesReport,
|
|
@@ -101,7 +95,7 @@ export function register(program: Command): void {
|
|
|
101
95
|
context
|
|
102
96
|
.command('audit')
|
|
103
97
|
.description(
|
|
104
|
-
'Report required sections, entry length, citations, reference form, catalog tables, provenance,
|
|
98
|
+
'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, its own entry cap, and its word weight, and wireframe states against their evidence folders',
|
|
105
99
|
)
|
|
106
100
|
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
107
101
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -133,8 +127,8 @@ export function register(program: Command): void {
|
|
|
133
127
|
'findings that are facts rather than judgments: a missing required',
|
|
134
128
|
'section and index drift. A context entry requires Overview and',
|
|
135
129
|
'Layout, and a wireframe requires Regions, States, Copy, and Not on',
|
|
136
|
-
'this surface. Entry length, reference form, table, provenance,
|
|
137
|
-
'
|
|
130
|
+
'this surface. Entry length, reference form, table, provenance, and the',
|
|
131
|
+
'record claim classification are judgments under both.',
|
|
138
132
|
'',
|
|
139
133
|
'Depth and bullet weight are stated over every markdown file rather',
|
|
140
134
|
'than over a context entry, so `canon markdown audit` measures them.',
|
|
@@ -653,24 +647,13 @@ async function runAudit(
|
|
|
653
647
|
)
|
|
654
648
|
}
|
|
655
649
|
|
|
656
|
-
const
|
|
657
|
-
? { kind: 'absent' }
|
|
658
|
-
: await loadNarration(root)
|
|
659
|
-
const entries = gateOnly
|
|
660
|
-
? []
|
|
661
|
-
: await measureFolders(
|
|
662
|
-
root,
|
|
663
|
-
folders,
|
|
664
|
-
narration.kind === 'loaded' ? narration : undefined,
|
|
665
|
-
)
|
|
650
|
+
const entries = gateOnly ? [] : await measureFolders(root, folders)
|
|
666
651
|
const drift = gateOnly ? [] : await auditIndexes(folders)
|
|
667
652
|
const sections = gateOnly ? [] : missingSections(root, folders, entries)
|
|
668
653
|
const length = gateOnly ? undefined : lengthFindings(entries)
|
|
669
654
|
// Absent under `--citations-only` and null when the project carries no
|
|
670
|
-
// record
|
|
671
|
-
//
|
|
672
|
-
// are different answers, and one value for both reports the second as the
|
|
673
|
-
// first.
|
|
655
|
+
// record. A run that never looked and a project with nothing to look at are
|
|
656
|
+
// different answers, and one value for both reports the second as the first.
|
|
674
657
|
const record = gateOnly ? undefined : await measureArchitecture(root)
|
|
675
658
|
const wireframes = gateOnly
|
|
676
659
|
? []
|
|
@@ -693,7 +676,6 @@ async function runAudit(
|
|
|
693
676
|
reportLength(length ?? [])
|
|
694
677
|
reportTables(entries)
|
|
695
678
|
reportProvenance(entries, folders)
|
|
696
|
-
reportNarration(entries, folders, narration)
|
|
697
679
|
reportDrift(drift)
|
|
698
680
|
reportRecord(record, root)
|
|
699
681
|
reportWireframeStates(wireframes)
|
|
@@ -722,10 +704,11 @@ async function runAudit(
|
|
|
722
704
|
// answers, and one wrong restatement is a cause reported against the
|
|
723
705
|
// wrong entry.
|
|
724
706
|
//
|
|
725
|
-
// Absent rather than empty under `--citations-only
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
707
|
+
// Absent rather than empty under `--citations-only`. That mode
|
|
708
|
+
// measures no entry, so an empty array here reads as a corpus with
|
|
709
|
+
// nothing past the checkpoint rather than as a run that never looked,
|
|
710
|
+
// and collapsing the two reports a measured answer for a run that
|
|
711
|
+
// never opened an entry.
|
|
729
712
|
length,
|
|
730
713
|
missingSections: sections,
|
|
731
714
|
indexDrift: drift,
|
|
@@ -747,20 +730,6 @@ async function runAudit(
|
|
|
747
730
|
// caller that read it before wireframes owed a section, since turning
|
|
748
731
|
// it into this map would break that caller without an error.
|
|
749
732
|
requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
|
|
750
|
-
// Three states rather than two, so a record showing no finding says
|
|
751
|
-
// which terms were looked for. The key is absent when the run never
|
|
752
|
-
// scanned, which is `--citations-only`, and null when it scanned and
|
|
753
|
-
// no rule published both headings. Collapsing the first into null
|
|
754
|
-
// reports an absent rule against a run that never opened one.
|
|
755
|
-
narration: gateOnly
|
|
756
|
-
? undefined
|
|
757
|
-
: narration.kind === 'loaded'
|
|
758
|
-
? {
|
|
759
|
-
source: narration.source,
|
|
760
|
-
pronouns: narration.pronouns,
|
|
761
|
-
verbs: narration.verbs,
|
|
762
|
-
}
|
|
763
|
-
: null,
|
|
764
733
|
},
|
|
765
734
|
})}\n`,
|
|
766
735
|
)
|
|
@@ -1144,75 +1113,6 @@ function reportProvenance(
|
|
|
1144
1113
|
)
|
|
1145
1114
|
}
|
|
1146
1115
|
|
|
1147
|
-
/**
|
|
1148
|
-
* Reports the bullets narrating a decision the bullet above them replaced.
|
|
1149
|
-
*
|
|
1150
|
-
* The reach line names the rule the sets were read from, since the check is
|
|
1151
|
-
* silent when no rule publishes them and a run that scanned nothing otherwise
|
|
1152
|
-
* prints the same clean line as a run that scanned everything.
|
|
1153
|
-
*
|
|
1154
|
-
* The legitimate-hit line is here for the same reason the provenance section
|
|
1155
|
-
* says a marker is a judgment. The standard keeps a rejected alternative and
|
|
1156
|
-
* why it lost, which is a back-reference in the past tense by construction, and
|
|
1157
|
-
* no measure separates one from the shape the rule bans.
|
|
1158
|
-
*/
|
|
1159
|
-
function reportNarration(
|
|
1160
|
-
entries: readonly EntryReport[],
|
|
1161
|
-
folders: readonly AuditedFolder[],
|
|
1162
|
-
narration: Narration,
|
|
1163
|
-
): void {
|
|
1164
|
-
logStep('Narration')
|
|
1165
|
-
|
|
1166
|
-
const governed = folders.filter(governsContent)
|
|
1167
|
-
if (governed.length === 0) {
|
|
1168
|
-
logInfo(
|
|
1169
|
-
`Out of scope. The rule is stated in the standard governing canon/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
|
|
1170
|
-
)
|
|
1171
|
-
return
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
if (narration.kind === 'absent') {
|
|
1175
|
-
logWarn(
|
|
1176
|
-
`Not scanned. No rule under .claude/rules/ or governance/rules/ publishes both ${PRONOUN_HEADING} and ${VERB_HEADING}.`,
|
|
1177
|
-
)
|
|
1178
|
-
return
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
logInfo(
|
|
1182
|
-
`Covers canon/${PROVENANCE_FOLDER}/ alone, reading ${plural(narration.pronouns.length, 'pronoun')} and ${plural(narration.verbs.length, 'verb')} from ${narration.source}.`,
|
|
1183
|
-
)
|
|
1184
|
-
logInfo(
|
|
1185
|
-
'A rejected alternative is a legitimate hit, since the standard keeps what was tried and why it lost.',
|
|
1186
|
-
)
|
|
1187
|
-
|
|
1188
|
-
const carrying = entries
|
|
1189
|
-
.filter((entry) => entry.narration.length > 0)
|
|
1190
|
-
.sort((a, b) => b.narration.length - a.narration.length)
|
|
1191
|
-
|
|
1192
|
-
if (carrying.length === 0) {
|
|
1193
|
-
logInfo('No bullet narrates a decision the bullet above it replaced.')
|
|
1194
|
-
return
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
const total = carrying.reduce((sum, entry) => sum + entry.narration.length, 0)
|
|
1198
|
-
logWarn(
|
|
1199
|
-
`${plural(total, 'bullet')} to read across ${carrying.length} ${carrying.length === 1 ? 'entry' : 'entries'}`,
|
|
1200
|
-
)
|
|
1201
|
-
pipeOutput(
|
|
1202
|
-
carrying
|
|
1203
|
-
.map(
|
|
1204
|
-
(entry) =>
|
|
1205
|
-
`${entry.rel} ${plural(entry.narration.length, 'bullet')}\n${entry.narration
|
|
1206
|
-
.map(
|
|
1207
|
-
(found) =>
|
|
1208
|
-
` :${found.line} ${found.pronoun} with ${found.verb}`,
|
|
1209
|
-
)
|
|
1210
|
-
.join('\n')}`,
|
|
1211
|
-
)
|
|
1212
|
-
.join('\n'),
|
|
1213
|
-
)
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
1116
|
/** How each classification reads in the report. */
|
|
1217
1117
|
const CLAIM_LABEL: Record<string, string> = {
|
|
1218
1118
|
countable: 'countable claim',
|
package/src/commands/sync.ts
CHANGED
|
@@ -133,9 +133,9 @@ function renderCheck(report: CheckReport): void {
|
|
|
133
133
|
logWarn(entry.notice ?? `${entry.rel} (${entry.state})`)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const { stale, customized, drifted, stranded, orphaned, missing } =
|
|
136
|
+
const { stale, customized, drifted, stranded, retired, orphaned, missing } =
|
|
137
137
|
domain.counts
|
|
138
|
-
if (stale + customized + drifted + stranded === 0) {
|
|
138
|
+
if (stale + customized + drifted + stranded + retired === 0) {
|
|
139
139
|
logInfo(orphaned === 0 ? 'up to date' : `up to date (${orphaned} local)`)
|
|
140
140
|
}
|
|
141
141
|
// A local file is not a deficiency and a missing rule is, so this never
|
package/src/context/audit.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises'
|
|
|
2
2
|
import { basename, relative } from 'node:path'
|
|
3
3
|
import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
|
|
4
4
|
import type { AuditedFolder } from '@/context/folders'
|
|
5
|
-
import type { NarrationTerms } from '@/context/narration'
|
|
6
5
|
import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
7
6
|
import { documentHeight } from '@/markdown/structure'
|
|
8
7
|
import { isStubSeed } from '@/seed-marker'
|
|
@@ -80,10 +79,6 @@ const TABLE_ROW = /^\s*\|/
|
|
|
80
79
|
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
81
80
|
const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
|
|
82
81
|
|
|
83
|
-
const TOP_BULLET = /^-\s+(.+)$/
|
|
84
|
-
/** Any indented line, which is a nested bullet or a wrapped continuation. */
|
|
85
|
-
const INSIDE_LIST = /^\s+\S/
|
|
86
|
-
|
|
87
82
|
/**
|
|
88
83
|
* Spellings of how the domain reached its shape rather than what it is now.
|
|
89
84
|
*
|
|
@@ -156,9 +151,9 @@ const SENTENCE_END = /[.!?:;]\s(?=[^.!?:;]*$)/
|
|
|
156
151
|
/**
|
|
157
152
|
* The folder whose standard carries the exclusion above.
|
|
158
153
|
*
|
|
159
|
-
*
|
|
160
|
-
* sections
|
|
161
|
-
*
|
|
154
|
+
* Two measures narrow here rather than one: provenance and the required
|
|
155
|
+
* sections. The name is the first of them because it was the first, and it
|
|
156
|
+
* stays because renaming a constant the
|
|
162
157
|
* JSON record publishes as `checkpoints.provenanceFolder` breaks every consumer
|
|
163
158
|
* reading that field for a gain of one word.
|
|
164
159
|
*
|
|
@@ -202,14 +197,6 @@ export interface BareReferenceFinding {
|
|
|
202
197
|
readonly name: string
|
|
203
198
|
}
|
|
204
199
|
|
|
205
|
-
export interface NarrationFinding {
|
|
206
|
-
readonly line: number
|
|
207
|
-
/** The opening that points back at the bullet above. */
|
|
208
|
-
readonly pronoun: string
|
|
209
|
-
/** The past-tense verb that turns the back-reference into a narration. */
|
|
210
|
-
readonly verb: string
|
|
211
|
-
}
|
|
212
|
-
|
|
213
200
|
export interface EntryReport {
|
|
214
201
|
readonly rel: string
|
|
215
202
|
/**
|
|
@@ -229,12 +216,6 @@ export interface EntryReport {
|
|
|
229
216
|
readonly catalogTables: readonly TableFinding[]
|
|
230
217
|
/** Empty for an entry no standard bans a change narrative in. */
|
|
231
218
|
readonly provenance: readonly ProvenanceFinding[]
|
|
232
|
-
/**
|
|
233
|
-
* Empty outside the governed folder, and empty on a run whose caller loaded
|
|
234
|
-
* no term sets. The report distinguishes the two from the vocabulary itself,
|
|
235
|
-
* since an empty list here is silent about which one produced it.
|
|
236
|
-
*/
|
|
237
|
-
readonly narration: readonly NarrationFinding[]
|
|
238
219
|
/**
|
|
239
220
|
* References naming a sibling entry by bare filename, and empty for a caller
|
|
240
221
|
* that passed no sibling names. Which entries have siblings worth matching is
|
|
@@ -434,125 +415,6 @@ function stampsMeasurement(text: string, index: number): boolean {
|
|
|
434
415
|
return STAMPING.test(clause) || STAMPING_RUN.test(clause)
|
|
435
416
|
}
|
|
436
417
|
|
|
437
|
-
function escape(term: string): string {
|
|
438
|
-
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Copulas that turn the verb behind them into a present-tense passive.
|
|
443
|
-
*
|
|
444
|
-
* `used to` is the term this exists for, since `is used to resolve the folder`
|
|
445
|
-
* is the passive of `use` rather than the past habitual the set means. This
|
|
446
|
-
* list is English grammar rather than corpus vocabulary, so it stays in code
|
|
447
|
-
* while the two tunable sets stay in the rule, and a rule publishing two of
|
|
448
|
-
* three headings would be a fourth absent state to carry for no tuning anyone
|
|
449
|
-
* wants.
|
|
450
|
-
*
|
|
451
|
-
* What it costs is the perfect passive. `has been superseded` narrates a
|
|
452
|
-
* supersession and is rejected with the rest, which is a recall gap taken
|
|
453
|
-
* knowingly on the trade this check already makes everywhere else.
|
|
454
|
-
*/
|
|
455
|
-
const COPULA = ['is', 'are', 'be', 'been', 'being', 'was', 'were']
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Finds the bullets narrating a decision the bullet above them replaced.
|
|
459
|
-
*
|
|
460
|
-
* The signal is structural rather than lexical, and the corpus is what decides
|
|
461
|
-
* that. The terms carrying clean signal for a supersession are too rare to
|
|
462
|
-
* catch anything, and the one term that would have caught the known case is
|
|
463
|
-
* `now`, which appears 57 times across 24 entries in correct present-tense
|
|
464
|
-
* prose. What separates the shape instead is a bullet pointing back at its
|
|
465
|
-
* sibling and putting the sibling's design in the past: an opening pronoun with
|
|
466
|
-
* no antecedent of its own, plus a past-tense verb, plus a bullet above it to
|
|
467
|
-
* refer to. Precision is the whole value, so recall is the accepted exposure
|
|
468
|
-
* and a narration written as a single bullet is not reached.
|
|
469
|
-
*
|
|
470
|
-
* The pronoun is matched cased and anchored, since a mid-sentence `this` is a
|
|
471
|
-
* determiner rather than a back-reference. The verb is matched uncased anywhere
|
|
472
|
-
* in the bullet, since the tense is what carries the signal wherever it sits,
|
|
473
|
-
* and rejected behind a `COPULA` for the reason stated there. Displayed spans
|
|
474
|
-
* are masked so a term quoted in backticks is not read as prose the entry
|
|
475
|
-
* writes, and fenced blocks are skipped for the reason the scans above skip
|
|
476
|
-
* them.
|
|
477
|
-
*
|
|
478
|
-
* A blank line does not end the run. Markdown reads two bullets around one as a
|
|
479
|
-
* single loose list, so breaking there would leave the shape reachable by
|
|
480
|
-
* anyone who spaced their bullets out. What ends a run is content that is
|
|
481
|
-
* neither a bullet nor indented under one, which the fenced branch below has to
|
|
482
|
-
* answer for itself because a fenced line is skipped before that test.
|
|
483
|
-
*/
|
|
484
|
-
function narration(
|
|
485
|
-
lines: readonly BodyLine[],
|
|
486
|
-
terms: NarrationTerms,
|
|
487
|
-
): NarrationFinding[] {
|
|
488
|
-
if (terms.pronouns.length === 0 || terms.verbs.length === 0) return []
|
|
489
|
-
|
|
490
|
-
const findings: NarrationFinding[] = []
|
|
491
|
-
let following = false
|
|
492
|
-
let fenceBlock: number | undefined
|
|
493
|
-
let fenceInsideList = false
|
|
494
|
-
|
|
495
|
-
for (const line of lines) {
|
|
496
|
-
// A fenced line is never a bullet, but an unindented block still ends the
|
|
497
|
-
// run. CommonMark reads a fence at column zero as interrupting the list, so
|
|
498
|
-
// the bullets around it are two lists and the second has no antecedent
|
|
499
|
-
// above it. A block indented under its bullet stays inside the item.
|
|
500
|
-
//
|
|
501
|
-
// The opening delimiter decides for every line of its own block. Reading
|
|
502
|
-
// each line instead ends the run on a blank line inside an indented fence,
|
|
503
|
-
// which has no indentation to read, and on a content line at column zero,
|
|
504
|
-
// which CommonMark permits since only the fence's own indent is stripped.
|
|
505
|
-
//
|
|
506
|
-
// The block index is what re-opens that decision, so two blocks written
|
|
507
|
-
// with nothing between them are answered separately rather than the second
|
|
508
|
-
// inheriting the first. Indentation stays the test here because what
|
|
509
|
-
// interrupts a list is this check's own judgment, and the walker only says
|
|
510
|
-
// which lines share a block.
|
|
511
|
-
if (line.fenced) {
|
|
512
|
-
if (line.fenceBlock !== fenceBlock) {
|
|
513
|
-
fenceBlock = line.fenceBlock
|
|
514
|
-
fenceInsideList = INSIDE_LIST.test(line.text)
|
|
515
|
-
}
|
|
516
|
-
if (!fenceInsideList) following = false
|
|
517
|
-
continue
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (line.text.trim() === '') continue
|
|
521
|
-
|
|
522
|
-
const bullet = line.text.match(TOP_BULLET)
|
|
523
|
-
if (!bullet) {
|
|
524
|
-
// A nested bullet and a wrapped continuation both sit inside the list, so
|
|
525
|
-
// neither ends the run. Anything else does, which is what keeps a bullet
|
|
526
|
-
// opening the list under a heading from reading as a reply to the last
|
|
527
|
-
// bullet of the list before it.
|
|
528
|
-
if (!INSIDE_LIST.test(line.text)) following = false
|
|
529
|
-
continue
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const text = maskDisplayed(bullet[1])
|
|
533
|
-
|
|
534
|
-
if (following) {
|
|
535
|
-
const pronoun = terms.pronouns.find((term) =>
|
|
536
|
-
new RegExp(`^${escape(term)}\\b`).test(text),
|
|
537
|
-
)
|
|
538
|
-
const verb = pronoun
|
|
539
|
-
? terms.verbs.find((term) =>
|
|
540
|
-
new RegExp(
|
|
541
|
-
`(?<!\\b(?:${COPULA.join('|')})\\s+)\\b${escape(term)}\\b`,
|
|
542
|
-
'i',
|
|
543
|
-
).test(text),
|
|
544
|
-
)
|
|
545
|
-
: undefined
|
|
546
|
-
|
|
547
|
-
if (pronoun && verb) findings.push({ line: line.number, pronoun, verb })
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
following = true
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
return findings
|
|
554
|
-
}
|
|
555
|
-
|
|
556
418
|
/**
|
|
557
419
|
* Finds which required sections the entry declares.
|
|
558
420
|
*
|
|
@@ -639,7 +501,6 @@ export function measureEntry(
|
|
|
639
501
|
rel: string,
|
|
640
502
|
source: string,
|
|
641
503
|
governsContent = true,
|
|
642
|
-
terms?: NarrationTerms,
|
|
643
504
|
siblings: readonly string[] = [],
|
|
644
505
|
required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
|
|
645
506
|
): EntryReport {
|
|
@@ -650,7 +511,6 @@ export function measureEntry(
|
|
|
650
511
|
lines: documentHeight(source),
|
|
651
512
|
catalogTables: catalogTables(lines),
|
|
652
513
|
provenance: governsContent ? provenance(lines) : [],
|
|
653
|
-
narration: governsContent && terms ? narration(lines, terms) : [],
|
|
654
514
|
bareReferences: bareReferences(lines, siblings),
|
|
655
515
|
sections: declaredSections(lines, required),
|
|
656
516
|
stub: isStubSeed(source),
|
|
@@ -714,7 +574,6 @@ function historyCause(entry: EntryReport): LengthCause {
|
|
|
714
574
|
export async function measureFolders(
|
|
715
575
|
root: string,
|
|
716
576
|
folders: readonly AuditedFolder[],
|
|
717
|
-
terms?: NarrationTerms,
|
|
718
577
|
): Promise<EntryReport[]> {
|
|
719
578
|
const reports: EntryReport[] = []
|
|
720
579
|
|
|
@@ -731,7 +590,6 @@ export async function measureFolders(
|
|
|
731
590
|
relative(root, path),
|
|
732
591
|
await readFile(path, 'utf8'),
|
|
733
592
|
governsContent(folder),
|
|
734
|
-
terms,
|
|
735
593
|
names.filter((name) => name !== self),
|
|
736
594
|
requiredSections(folder),
|
|
737
595
|
),
|
package/src/gate/measures.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isOverLength,
|
|
13
13
|
measureArchitecture,
|
|
14
14
|
} from '@/context/architecture'
|
|
15
|
+
import { auditSkills } from '@/claude/skills-audit'
|
|
15
16
|
import { listRepositoryFiles } from '@/git-files'
|
|
16
17
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
17
18
|
import { resolveMarkdown } from '@/markdown/files'
|
|
@@ -277,6 +278,42 @@ export const architectureRecord: Measure = async (ctx) => {
|
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Every skill body and reference against the no-dated-provenance rule, across
|
|
283
|
+
* both corpora `auditSkills` reads.
|
|
284
|
+
*
|
|
285
|
+
* Read in-process rather than through `claude skills audit`, whose exit code
|
|
286
|
+
* stays a missing requirement only, so a target running the verb is told about
|
|
287
|
+
* a date without failing on one. This repository holds the corpus at zero,
|
|
288
|
+
* which is what lets the same finding fail here.
|
|
289
|
+
*/
|
|
290
|
+
export const skillProvenance: Measure = async (ctx) => {
|
|
291
|
+
const report = await auditSkills(ctx.root)
|
|
292
|
+
if (report.corpora.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
emissions: [],
|
|
295
|
+
unmeasured: 'no skill corpus resolved, so no body was read.',
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const found = report.datedProvenance
|
|
300
|
+
if (found.length > 0) {
|
|
301
|
+
const where = found
|
|
302
|
+
.map((finding) => `${finding.rel} ${finding.detail}`)
|
|
303
|
+
.join(', ')
|
|
304
|
+
return {
|
|
305
|
+
emissions: [],
|
|
306
|
+
failure: `${plural(found.length, 'date')} in skill prose: ${where}. Move the incident to REQUIREMENT.md under Gap or leave it to git, or fence the date or put it in a code span when it is an example.`,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
emissions: [
|
|
312
|
+
info(`No dated provenance across ${plural(report.skills, 'skill')}`),
|
|
313
|
+
],
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
280
317
|
/**
|
|
281
318
|
* Every tracked markdown document against the whole-document ceiling.
|
|
282
319
|
*
|
package/src/gate/stages.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
seedStandards,
|
|
14
14
|
rawFieldFileReference,
|
|
15
15
|
shippedReferences,
|
|
16
|
+
skillProvenance,
|
|
16
17
|
standardCriteria,
|
|
17
18
|
unreferencedRules,
|
|
18
19
|
visualPathGlobs,
|
|
@@ -468,6 +469,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
468
469
|
],
|
|
469
470
|
success: 'Skill requirements present',
|
|
470
471
|
},
|
|
472
|
+
{
|
|
473
|
+
// The audit verb only reports a dated line, so a target is told without
|
|
474
|
+
// failing. This stage makes the same finding fail here, where the corpus is
|
|
475
|
+
// held at zero. Unscoped, since any branch can add a line to any body.
|
|
476
|
+
id: 'skill-provenance',
|
|
477
|
+
label: 'Skill provenance',
|
|
478
|
+
checks: [{ kind: 'measure', measure: skillProvenance }],
|
|
479
|
+
},
|
|
471
480
|
{
|
|
472
481
|
id: 'standard-criteria',
|
|
473
482
|
label: 'Standard success criteria',
|
package/src/gov/adapter.ts
CHANGED
|
@@ -44,12 +44,13 @@ export function indexSourceRules(root: string): Map<string, string> {
|
|
|
44
44
|
* path, so a rule that moved between subdirectories in the toolkit still
|
|
45
45
|
* syncs into the subdirectory the target already uses.
|
|
46
46
|
*
|
|
47
|
-
* Declares no `projectSubdir`.
|
|
48
|
-
* `.claude/rules/project
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
47
|
+
* Declares `ownsInstalledRoot` and no `projectSubdir`. A project keeps its own
|
|
48
|
+
* rules under `.claude/rules/project/`, which `installedRoot` narrowing to
|
|
49
|
+
* `.claude/rules/canon/` leaves outside the walk, so everything the walk does
|
|
50
|
+
* reach is the toolkit's. A rule there with no source is one the toolkit
|
|
51
|
+
* retired or renamed, and the sync deletes it rather than leaving it loaded.
|
|
52
|
+
* A `projectSubdir` would compute a stale `canon/project/` destination, which
|
|
53
|
+
* is wrong rather than merely redundant.
|
|
53
54
|
*/
|
|
54
55
|
export function createGovAdapter(root: string): SyncAdapter {
|
|
55
56
|
const index = indexSourceRules(root)
|
|
@@ -65,6 +66,7 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
65
66
|
index.get(basename(file.path, '.md')),
|
|
66
67
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
67
68
|
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
69
|
+
ownsInstalledRoot: true,
|
|
68
70
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
69
71
|
}
|
|
70
72
|
}
|
package/src/records/validate.ts
CHANGED
|
@@ -271,7 +271,9 @@ const PLAN_SECTIONS = [
|
|
|
271
271
|
'Summary',
|
|
272
272
|
'Constraints',
|
|
273
273
|
'Files to touch',
|
|
274
|
+
'Verification',
|
|
274
275
|
'Risks',
|
|
276
|
+
'Review focus',
|
|
275
277
|
'Questions',
|
|
276
278
|
] as const
|
|
277
279
|
|
|
@@ -280,7 +282,9 @@ type PlanSection = (typeof PLAN_SECTIONS)[number]
|
|
|
280
282
|
const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
281
283
|
'Summary',
|
|
282
284
|
'Files to touch',
|
|
285
|
+
'Verification',
|
|
283
286
|
'Risks',
|
|
287
|
+
'Review focus',
|
|
284
288
|
'Questions',
|
|
285
289
|
]
|
|
286
290
|
|
|
@@ -293,7 +297,7 @@ const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
|
|
|
293
297
|
|
|
294
298
|
/**
|
|
295
299
|
* A section opens as a bold label or as an H2 and both count. The corpus writes
|
|
296
|
-
* `Summary` as a heading and the
|
|
300
|
+
* `Summary` as a heading and the others as bold labels, and roughly a fifth
|
|
297
301
|
* of it swaps one for the other. Reporting the variant would fail nearly every
|
|
298
302
|
* plan present on the rule a reader is least served by, which is what teaches
|
|
299
303
|
* them to skip the output.
|
package/src/sync/check.ts
CHANGED
|
@@ -79,6 +79,7 @@ export interface StateCounts {
|
|
|
79
79
|
readonly orphaned: number
|
|
80
80
|
readonly stranded: number
|
|
81
81
|
readonly missing: number
|
|
82
|
+
readonly retired: number
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export interface DomainReport {
|
|
@@ -314,6 +315,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
314
315
|
orphaned: count(entries, 'orphaned'),
|
|
315
316
|
stranded: count(entries, 'stranded'),
|
|
316
317
|
missing: count(entries, 'missing'),
|
|
318
|
+
retired: count(entries, 'retired'),
|
|
317
319
|
}
|
|
318
320
|
}
|
|
319
321
|
|
|
@@ -342,6 +344,9 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
342
344
|
* sync that adds a rule silently changes what a project is governed by, and
|
|
343
345
|
* nobody chose that, so gating CI on the count would pressure a target into
|
|
344
346
|
* adopting a rule nobody picked.
|
|
347
|
+
*
|
|
348
|
+
* `retired` counts, since a sync deletes it mechanically. A target's CI goes
|
|
349
|
+
* red the day a release retires a rule it holds, and one sync clears it.
|
|
345
350
|
*/
|
|
346
351
|
export function hasDrift(report: CheckReport): boolean {
|
|
347
352
|
if (report.unmigrated.length > 0) return true
|
|
@@ -351,7 +356,8 @@ export function hasDrift(report: CheckReport): boolean {
|
|
|
351
356
|
domain.counts.stale +
|
|
352
357
|
domain.counts.customized +
|
|
353
358
|
domain.counts.drifted +
|
|
354
|
-
domain.counts.stranded
|
|
359
|
+
domain.counts.stranded +
|
|
360
|
+
domain.counts.retired >
|
|
355
361
|
0,
|
|
356
362
|
)
|
|
357
363
|
}
|