@erclx/canon 5.4.0 → 5.6.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/bash-cli-script/REQUIREMENT.md +10 -9
- package/claude/skills/bash-cli-script/SKILL.md +10 -7
- package/claude/skills/bash-cli-script/references/template.md +4 -28
- package/claude/skills/codebase-layout/REQUIREMENT.md +3 -0
- package/claude/skills/codebase-layout/SKILL.md +28 -0
- package/claude/skills/codebase-layout/references/adopted.md +6 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +4 -0
- package/claude/skills/plan-feature/SKILL.md +4 -0
- package/claude/skills/read-frames/REQUIREMENT.md +1 -0
- package/claude/skills/read-frames/SKILL.md +1 -0
- package/claude/skills/teach-workspace/REQUIREMENT.md +3 -0
- package/claude/skills/teach-workspace/SKILL.md +6 -5
- package/claude/skills/teach-workspace/references/lesson-craft.md +8 -1
- package/claude/skills/test-craft/REQUIREMENT.md +4 -1
- package/claude/skills/test-craft/SKILL.md +20 -2
- package/claude/skills/test-craft/references/adopted.md +3 -1
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +11 -1
- package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
- package/claude/skills/ux-measure/SKILL.md +3 -1
- package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
- package/claude/skills/ux-walkthrough/SKILL.md +1 -0
- package/docs/agents/architecture-record.md +5 -3
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +3 -3
- package/docs/agents/index.md +3 -3
- package/docs/agents/skills-audit.md +6 -3
- package/docs/agents/teach-authoring.md +3 -3
- package/docs/agents/teach.md +11 -5
- package/docs/workflow/skill-map.md +1 -2
- package/governance/rules/lang/120-bash.md +4 -5
- package/package.json +1 -1
- package/src/audits/catalog.ts +7 -1
- package/src/claude/cases/authoring.ts +0 -5
- package/src/claude/skills-audit.ts +81 -1
- package/src/commands/claude.ts +33 -0
- package/src/commands/context.ts +25 -2
- package/src/commands/serve.ts +2 -92
- package/src/commands/teach.ts +142 -17
- package/src/context/architecture.ts +82 -5
- package/src/context/gate.ts +10 -3
- package/src/gate/measures.ts +52 -5
- package/src/gate/stages.ts +10 -1
- package/src/serve/report.ts +110 -0
- package/src/teach/nav.ts +12 -1
- package/standards/architecture.md +14 -3
- package/standards/index.md +1 -0
- package/standards/skill-practice.md +51 -0
- package/standards/skill.md +3 -1
- package/claude/skills/bash-script/REQUIREMENT.md +0 -36
- package/claude/skills/bash-script/SKILL.md +0 -100
- package/claude/skills/bash-script/references/patterns.md +0 -250
- package/claude/skills/bash-script/references/prompts.md +0 -100
package/src/commands/teach.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
logServing,
|
|
5
|
+
parsePort,
|
|
6
|
+
serveFields,
|
|
7
|
+
waitForInterrupt,
|
|
8
|
+
} from '@/serve/report'
|
|
9
|
+
import { DEFAULT_PORT, startServer } from '@/serve/static'
|
|
3
10
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
|
-
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
11
|
+
import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
|
|
5
12
|
import type { RenderOutcome } from '@/teach/render'
|
|
6
13
|
import {
|
|
7
14
|
defineTerms,
|
|
@@ -83,6 +90,12 @@ interface NavCommandOptions {
|
|
|
83
90
|
readonly root?: string
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
interface UpCommandOptions {
|
|
94
|
+
readonly json?: boolean
|
|
95
|
+
readonly port?: string
|
|
96
|
+
readonly root?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
interface RenderCommandOptions {
|
|
87
100
|
readonly json?: boolean
|
|
88
101
|
}
|
|
@@ -96,11 +109,12 @@ export function register(program: Command): void {
|
|
|
96
109
|
'after',
|
|
97
110
|
[
|
|
98
111
|
'',
|
|
99
|
-
'
|
|
100
|
-
' canon
|
|
112
|
+
'Viewing a workspace:',
|
|
113
|
+
' canon teach up <topic>',
|
|
101
114
|
'',
|
|
102
|
-
'
|
|
103
|
-
'
|
|
115
|
+
'It refreshes the chrome and serves the workspace. To serve without',
|
|
116
|
+
'rewriting anything, canon teach list <topic> prints the canon serve',
|
|
117
|
+
'line, with the teach folder written from where you stand.',
|
|
104
118
|
'',
|
|
105
119
|
].join('\n'),
|
|
106
120
|
)
|
|
@@ -178,6 +192,7 @@ export function register(program: Command): void {
|
|
|
178
192
|
'It derives the ordinal from the highest already present and writes',
|
|
179
193
|
'MISSION.md, RESOURCES.md, and GLOSSARY.md. A topic another workspace',
|
|
180
194
|
'already covers is refused, since a second one forks the records.',
|
|
195
|
+
'It creates a workspace and serves nothing: canon teach up views one.',
|
|
181
196
|
'',
|
|
182
197
|
'Examples:',
|
|
183
198
|
' canon teach open regular-expressions --subject "Reading and writing regular expressions" \\',
|
|
@@ -365,6 +380,10 @@ export function register(program: Command): void {
|
|
|
365
380
|
'the prev/next footer nav, and the behavior scripts. The authored',
|
|
366
381
|
'<h1>, lede, body, and quiz are left untouched.',
|
|
367
382
|
'',
|
|
383
|
+
"A workspace's generated assets/base.css is rewritten on every run, so",
|
|
384
|
+
'a CLI upgrade reaches it. Its course.css is never rewritten, and a',
|
|
385
|
+
'workspace holding only a course.css is not given a base.css.',
|
|
386
|
+
'',
|
|
368
387
|
'A lesson missing one of the four chrome markers is refused rather',
|
|
369
388
|
"than rewritten, reported by name in the JSON record's skipped list",
|
|
370
389
|
'and on stderr, while every other lesson still rewrites.',
|
|
@@ -379,6 +398,45 @@ export function register(program: Command): void {
|
|
|
379
398
|
process.exitCode = await runNav(topic, opts)
|
|
380
399
|
})
|
|
381
400
|
|
|
401
|
+
teach
|
|
402
|
+
.command('up')
|
|
403
|
+
.description('Refresh the chrome with nav, then serve the teach folder')
|
|
404
|
+
.argument('[topic]', 'Workspace folder or topic the printed link opens')
|
|
405
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
406
|
+
.option('--port <number>', `Port to try first, default ${DEFAULT_PORT}`)
|
|
407
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
408
|
+
.option('--root <path>', 'Teach root, defaulting to the main worktree')
|
|
409
|
+
.addHelpText(
|
|
410
|
+
'after',
|
|
411
|
+
[
|
|
412
|
+
'',
|
|
413
|
+
'Exit codes:',
|
|
414
|
+
' 0 the server stopped after running',
|
|
415
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
416
|
+
'',
|
|
417
|
+
'Runs the nav pass for the topic, or for every workspace when none is',
|
|
418
|
+
'named, then serves the teach folder through the same server as',
|
|
419
|
+
'canon serve. The link opens the topic contents page, or the root',
|
|
420
|
+
'listing with no topic. A lesson nav skips is reported and the server',
|
|
421
|
+
'still starts, while a nav refusal starts no server.',
|
|
422
|
+
'',
|
|
423
|
+
'Viewing writes: every page and generated stylesheet nav owns is',
|
|
424
|
+
'rewritten first. canon teach list <topic> prints a canon serve line',
|
|
425
|
+
'that serves without rewriting.',
|
|
426
|
+
'',
|
|
427
|
+
'It runs until interrupted. A session wanting the link without waiting',
|
|
428
|
+
'starts it in the background and reads the record off stdout.',
|
|
429
|
+
'',
|
|
430
|
+
'Examples:',
|
|
431
|
+
' canon teach up regular-expressions',
|
|
432
|
+
' canon teach up --port 4000 --json',
|
|
433
|
+
'',
|
|
434
|
+
].join('\n'),
|
|
435
|
+
)
|
|
436
|
+
.action(async (topic: string | undefined, opts: UpCommandOptions) => {
|
|
437
|
+
process.exitCode = await runUp(topic, opts)
|
|
438
|
+
})
|
|
439
|
+
|
|
382
440
|
teach
|
|
383
441
|
.command('render')
|
|
384
442
|
.description('Render a lesson body block list to HTML')
|
|
@@ -805,21 +863,29 @@ function reportNav(
|
|
|
805
863
|
return reportRefusal('canon teach nav', outcome, emitJson, root)
|
|
806
864
|
|
|
807
865
|
if (emitJson) {
|
|
808
|
-
process.stdout.write(
|
|
809
|
-
`${JSON.stringify({
|
|
810
|
-
ok: true,
|
|
811
|
-
root: outcome.root,
|
|
812
|
-
contents: outcome.contents,
|
|
813
|
-
lessons: outcome.lessons,
|
|
814
|
-
reference: outcome.reference,
|
|
815
|
-
skipped: outcome.skipped,
|
|
816
|
-
unresolved: outcome.unresolved,
|
|
817
|
-
})}\n`,
|
|
818
|
-
)
|
|
866
|
+
process.stdout.write(`${JSON.stringify(navFields(outcome))}\n`)
|
|
819
867
|
return 0
|
|
820
868
|
}
|
|
821
869
|
|
|
822
870
|
intro('canon teach nav')
|
|
871
|
+
logNav(outcome)
|
|
872
|
+
outro()
|
|
873
|
+
return 0
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function navFields(outcome: NavGenerated): Record<string, unknown> {
|
|
877
|
+
return {
|
|
878
|
+
ok: true,
|
|
879
|
+
root: outcome.root,
|
|
880
|
+
contents: outcome.contents,
|
|
881
|
+
lessons: outcome.lessons,
|
|
882
|
+
reference: outcome.reference,
|
|
883
|
+
skipped: outcome.skipped,
|
|
884
|
+
unresolved: outcome.unresolved,
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function logNav(outcome: NavGenerated): void {
|
|
823
889
|
logStep('Root')
|
|
824
890
|
logInfo(outcome.root)
|
|
825
891
|
logStep('Contents')
|
|
@@ -842,8 +908,67 @@ function reportNav(
|
|
|
842
908
|
logWarn(`${reference.file}: lesson ${reference.lesson}`)
|
|
843
909
|
}
|
|
844
910
|
}
|
|
911
|
+
}
|
|
845
912
|
|
|
846
|
-
|
|
913
|
+
async function runUp(
|
|
914
|
+
topic: string | undefined,
|
|
915
|
+
opts: UpCommandOptions,
|
|
916
|
+
): Promise<number> {
|
|
917
|
+
const emitJson = opts.json ?? false
|
|
918
|
+
const root = await rootFor(opts.root)
|
|
919
|
+
|
|
920
|
+
const port = parsePort(opts.port)
|
|
921
|
+
if (port === undefined) {
|
|
922
|
+
return reportRefusal(
|
|
923
|
+
'canon teach up',
|
|
924
|
+
badInput(`${opts.port} is not a port`),
|
|
925
|
+
emitJson,
|
|
926
|
+
root,
|
|
927
|
+
)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const nav = await generateNav(root, topic)
|
|
931
|
+
if (!nav.ok) return reportRefusal('canon teach up', nav, emitJson, root)
|
|
932
|
+
|
|
933
|
+
// The server resolves its folder against the cwd, and the teach folder sits
|
|
934
|
+
// at the main worktree root, so it takes the absolute path a linked
|
|
935
|
+
// worktree can still reach.
|
|
936
|
+
const dir = resolve(teachDir(root))
|
|
937
|
+
const contents = nav.contents[0]
|
|
938
|
+
const entry =
|
|
939
|
+
topic === undefined || contents === undefined
|
|
940
|
+
? 'index.html'
|
|
941
|
+
: relative(dir, resolve(root, contents)).split(sep).join('/')
|
|
942
|
+
|
|
943
|
+
const served = startServer(dir, { port, entry })
|
|
944
|
+
if (!served.ok) {
|
|
945
|
+
// A serve refusal carries a reason the teach refusals do not list, so it
|
|
946
|
+
// is written here in the teach record's shape rather than through one.
|
|
947
|
+
if (emitJson) {
|
|
948
|
+
process.stderr.write(`${served.detail}\n`)
|
|
949
|
+
process.stdout.write(
|
|
950
|
+
`${JSON.stringify({ ok: false, root, reason: served.reason, message: served.detail, detail: [] })}\n`,
|
|
951
|
+
)
|
|
952
|
+
return 1
|
|
953
|
+
}
|
|
954
|
+
intro('canon teach up')
|
|
955
|
+
logStep('Refused')
|
|
956
|
+
logError(served.detail)
|
|
957
|
+
outro()
|
|
958
|
+
return 1
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (emitJson) {
|
|
962
|
+
process.stdout.write(
|
|
963
|
+
`${JSON.stringify({ ...navFields(nav), served: served.root, ...serveFields(served) })}\n`,
|
|
964
|
+
)
|
|
965
|
+
} else {
|
|
966
|
+
intro('canon teach up')
|
|
967
|
+
logNav(nav)
|
|
968
|
+
logServing(served)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
await waitForInterrupt(served.stop)
|
|
847
972
|
return 0
|
|
848
973
|
}
|
|
849
974
|
|
|
@@ -63,6 +63,12 @@ export interface DecisionReport {
|
|
|
63
63
|
* it. Never gates, per `standards/architecture.md`'s `## Length` section.
|
|
64
64
|
*/
|
|
65
65
|
readonly words: number
|
|
66
|
+
/**
|
|
67
|
+
* Whether a sentence outside code opens with `Revisit when`. Read on every
|
|
68
|
+
* record and required only by one stating the clause, so a project that
|
|
69
|
+
* never adopted it sees the reading without failing on it.
|
|
70
|
+
*/
|
|
71
|
+
readonly revisit: boolean
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export interface ArchitectureReport {
|
|
@@ -86,6 +92,12 @@ export interface ArchitectureReport {
|
|
|
86
92
|
* project that never adopted a cap is measured and never gated.
|
|
87
93
|
*/
|
|
88
94
|
readonly entryCap?: number
|
|
95
|
+
/**
|
|
96
|
+
* Whether the record states that every decision closes with a revisit
|
|
97
|
+
* sentence. Declared by the record for the reason the cap is, so a record
|
|
98
|
+
* that never wrote the clause is reported and never gated.
|
|
99
|
+
*/
|
|
100
|
+
readonly revisitRequired: boolean
|
|
89
101
|
readonly decisions: readonly DecisionReport[]
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -214,6 +226,8 @@ interface RawDecision {
|
|
|
214
226
|
readonly heading: string
|
|
215
227
|
readonly line: number
|
|
216
228
|
readonly body: string
|
|
229
|
+
/** The body with its fenced lines dropped, which is where prose is read. */
|
|
230
|
+
readonly prose: string
|
|
217
231
|
}
|
|
218
232
|
|
|
219
233
|
/**
|
|
@@ -230,7 +244,19 @@ interface RawDecision {
|
|
|
230
244
|
export function splitDecisions(source: string): RawDecision[] {
|
|
231
245
|
const lines = bodyLines(source)
|
|
232
246
|
const decisions: RawDecision[] = []
|
|
233
|
-
let open:
|
|
247
|
+
let open:
|
|
248
|
+
| { heading: string; line: number; body: string[]; prose: string[] }
|
|
249
|
+
| undefined
|
|
250
|
+
|
|
251
|
+
const close = (): void => {
|
|
252
|
+
if (!open) return
|
|
253
|
+
decisions.push({
|
|
254
|
+
heading: open.heading,
|
|
255
|
+
line: open.line,
|
|
256
|
+
body: open.body.join('\n'),
|
|
257
|
+
prose: open.prose.join('\n'),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
234
260
|
|
|
235
261
|
for (const line of lines) {
|
|
236
262
|
if (line.fenced) {
|
|
@@ -240,23 +266,24 @@ export function splitDecisions(source: string): RawDecision[] {
|
|
|
240
266
|
|
|
241
267
|
const heading = line.text.match(DECISION_HEADING)?.[1]
|
|
242
268
|
if (heading !== undefined) {
|
|
243
|
-
|
|
244
|
-
open = { heading, line: line.number, body: [] }
|
|
269
|
+
close()
|
|
270
|
+
open = { heading, line: line.number, body: [], prose: [] }
|
|
245
271
|
continue
|
|
246
272
|
}
|
|
247
273
|
|
|
248
274
|
// A decision runs to the next `###` or to the section that follows the
|
|
249
275
|
// decision list, so the risks below never read as the last entry's body.
|
|
250
276
|
if (SECTION_HEADING.test(line.text)) {
|
|
251
|
-
|
|
277
|
+
close()
|
|
252
278
|
open = undefined
|
|
253
279
|
continue
|
|
254
280
|
}
|
|
255
281
|
|
|
256
282
|
open?.body.push(line.text)
|
|
283
|
+
open?.prose.push(line.text)
|
|
257
284
|
}
|
|
258
285
|
|
|
259
|
-
|
|
286
|
+
close()
|
|
260
287
|
return decisions
|
|
261
288
|
}
|
|
262
289
|
|
|
@@ -383,6 +410,41 @@ export function readEntryCap(source: string): number | undefined {
|
|
|
383
410
|
return readCardinal(source.match(ENTRY_CAP_CLAUSE)?.[1])
|
|
384
411
|
}
|
|
385
412
|
|
|
413
|
+
const REVISIT_CLAUSE =
|
|
414
|
+
/\bevery\s+decision\s+closes\s+with\s+a\s+revisit\s+sentence\b/i
|
|
415
|
+
/**
|
|
416
|
+
* `Revisit when` opening a sentence: at the start of the text, after a line
|
|
417
|
+
* break, or after a sentence's closing stop. Mid-sentence it is not the
|
|
418
|
+
* sentence the standard asks for.
|
|
419
|
+
*/
|
|
420
|
+
const REVISIT_SENTENCE = /(?:^|\n|[.!?]\s+)\s*(?:[-*]\s+)?Revisit when\b/
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Whether the record states the revisit clause for itself, read off its prose
|
|
424
|
+
* alone so a template quoting the clause inside a fence does not adopt it.
|
|
425
|
+
*
|
|
426
|
+
* The clause is the record's own, like the cap, so wording that drifts past it
|
|
427
|
+
* falls back to reporting rather than to a requirement held here.
|
|
428
|
+
*/
|
|
429
|
+
export function readRevisitRule(source: string): boolean {
|
|
430
|
+
const prose = bodyLines(source)
|
|
431
|
+
.filter((line) => !line.fenced)
|
|
432
|
+
.map((line) => line.text)
|
|
433
|
+
.join('\n')
|
|
434
|
+
.replace(CODE_SPAN, ' ')
|
|
435
|
+
return REVISIT_CLAUSE.test(prose)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether a decision's prose carries a sentence opening `Revisit when`.
|
|
440
|
+
*
|
|
441
|
+
* Presence only, never content, so a vacuous sentence passes. A code span is
|
|
442
|
+
* dropped first, since a standard quoting the form is not stating one.
|
|
443
|
+
*/
|
|
444
|
+
export function hasRevisitSentence(prose: string): boolean {
|
|
445
|
+
return REVISIT_SENTENCE.test(prose.replace(CODE_SPAN, ' '))
|
|
446
|
+
}
|
|
447
|
+
|
|
386
448
|
/** Whether a read failed because nothing sits at the path. */
|
|
387
449
|
function isMissing(error: unknown): boolean {
|
|
388
450
|
const code = (error as { code?: unknown }).code
|
|
@@ -421,6 +483,7 @@ export async function measureArchitecture(
|
|
|
421
483
|
|
|
422
484
|
const allowances = readAllowances(source)
|
|
423
485
|
const entryCap = readEntryCap(source)
|
|
486
|
+
const revisitRequired = readRevisitRule(source)
|
|
424
487
|
const raw = splitDecisions(source)
|
|
425
488
|
const decisions = await Promise.all(
|
|
426
489
|
raw.map(async (entry) => {
|
|
@@ -433,6 +496,7 @@ export async function measureArchitecture(
|
|
|
433
496
|
...(quantified !== undefined && { quantified }),
|
|
434
497
|
checks: await namedChecks(root, entry.body),
|
|
435
498
|
words: wordCount(entry.body),
|
|
499
|
+
revisit: hasRevisitSentence(entry.prose),
|
|
436
500
|
}
|
|
437
501
|
}),
|
|
438
502
|
)
|
|
@@ -453,6 +517,7 @@ export async function measureArchitecture(
|
|
|
453
517
|
ceiling: ceilingFor(allowances, raw.length),
|
|
454
518
|
}),
|
|
455
519
|
...(entryCap !== undefined && { entryCap }),
|
|
520
|
+
revisitRequired,
|
|
456
521
|
decisions,
|
|
457
522
|
}
|
|
458
523
|
}
|
|
@@ -470,6 +535,18 @@ export function isOverCount(report: ArchitectureReport): boolean {
|
|
|
470
535
|
)
|
|
471
536
|
}
|
|
472
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The headings of decisions lacking a revisit sentence the record requires.
|
|
540
|
+
*
|
|
541
|
+
* Empty for a record stating no clause, for the reason `isOverCount` gives.
|
|
542
|
+
*/
|
|
543
|
+
export function missingRevisit(report: ArchitectureReport): string[] {
|
|
544
|
+
if (!report.revisitRequired) return []
|
|
545
|
+
return report.decisions
|
|
546
|
+
.filter((entry) => !entry.revisit)
|
|
547
|
+
.map((entry) => entry.heading)
|
|
548
|
+
}
|
|
549
|
+
|
|
473
550
|
/**
|
|
474
551
|
* Whether the record is longer than the ceiling it derives for itself.
|
|
475
552
|
*
|
package/src/context/gate.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface GateInput {
|
|
|
19
19
|
* gates under the same two modes and is false in the same two cases.
|
|
20
20
|
*/
|
|
21
21
|
readonly recordOverCount: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Whether a decision lacks the revisit sentence the record requires of
|
|
24
|
+
* every decision, which gates under the same two modes and is false in the
|
|
25
|
+
* same two cases, plus a record stating no such clause.
|
|
26
|
+
*/
|
|
27
|
+
readonly recordMissingRevisit: boolean
|
|
22
28
|
readonly sections: readonly SectionFinding[]
|
|
23
29
|
readonly drift: readonly FolderDrift[]
|
|
24
30
|
/**
|
|
@@ -73,8 +79,8 @@ export function hasSketchWithEvidence(
|
|
|
73
79
|
* Whether the audit found something that should fail the caller.
|
|
74
80
|
*
|
|
75
81
|
* An unresolved citation is a broken pointer and gates unconditionally, and so
|
|
76
|
-
* does a record past its own ceiling or its own entry cap
|
|
77
|
-
* each
|
|
82
|
+
* does a record past its own ceiling or its own entry cap, or missing a
|
|
83
|
+
* revisit sentence it requires: the record states each rule for itself, which makes those the measures here that are facts
|
|
78
84
|
* rather than thresholds a reader weighs. The findings `--gate` adds are
|
|
79
85
|
* the ones answerable from the file itself: a required section it does not
|
|
80
86
|
* declare, an index disagreeing with its folder, and a wireframe's States
|
|
@@ -88,13 +94,14 @@ export function isGating({
|
|
|
88
94
|
unresolvedCitations,
|
|
89
95
|
recordOverLength,
|
|
90
96
|
recordOverCount,
|
|
97
|
+
recordMissingRevisit,
|
|
91
98
|
sections,
|
|
92
99
|
drift,
|
|
93
100
|
wireframes,
|
|
94
101
|
widened,
|
|
95
102
|
}: GateInput): boolean {
|
|
96
103
|
if (unresolvedCitations > 0) return true
|
|
97
|
-
if (recordOverLength || recordOverCount) return true
|
|
104
|
+
if (recordOverLength || recordOverCount || recordMissingRevisit) return true
|
|
98
105
|
if (!widened) return false
|
|
99
106
|
|
|
100
107
|
return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
|
package/src/gate/measures.ts
CHANGED
|
@@ -11,8 +11,14 @@ import {
|
|
|
11
11
|
isOverCount,
|
|
12
12
|
isOverLength,
|
|
13
13
|
measureArchitecture,
|
|
14
|
+
missingRevisit,
|
|
14
15
|
} from '@/context/architecture'
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
auditSkills,
|
|
18
|
+
PRACTICE_LEDGER,
|
|
19
|
+
PRACTICE_SKILLS,
|
|
20
|
+
SHIPPED_CORPUS,
|
|
21
|
+
} from '@/claude/skills-audit'
|
|
16
22
|
import { listRepositoryFiles } from '@/git-files'
|
|
17
23
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
18
24
|
import { resolveMarkdown } from '@/markdown/files'
|
|
@@ -239,14 +245,15 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
|
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
242
|
-
* The architecture record against the
|
|
243
|
-
* line ceiling its allowances derive and the
|
|
248
|
+
* The architecture record against the three rules it states for itself: the
|
|
249
|
+
* line ceiling its allowances derive, the entry cap, and the revisit sentence
|
|
250
|
+
* closing each decision.
|
|
244
251
|
*
|
|
245
252
|
* Read in-process rather than through `context audit`, whose one gating stage
|
|
246
253
|
* here runs `--citations-only` and never opens the record, which is why the
|
|
247
254
|
* line ceiling went unenforced by `bun run check` until this stage. A project
|
|
248
|
-
* carrying no record, or a record stating
|
|
249
|
-
*
|
|
255
|
+
* carrying no record, or a record stating none of the three, passes, since
|
|
256
|
+
* each rule belongs to the record rather than to the toolkit.
|
|
250
257
|
*/
|
|
251
258
|
export const architectureRecord: Measure = async (ctx) => {
|
|
252
259
|
const report = await measureArchitecture(ctx.root)
|
|
@@ -255,11 +262,14 @@ export const architectureRecord: Measure = async (ctx) => {
|
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
const decisions = report.decisions.length
|
|
265
|
+
const lackingRevisit = missingRevisit(report)
|
|
258
266
|
const failures = [
|
|
259
267
|
isOverCount(report) &&
|
|
260
268
|
`${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
|
|
261
269
|
isOverLength(report) &&
|
|
262
270
|
`${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
|
|
271
|
+
lackingRevisit.length > 0 &&
|
|
272
|
+
`No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
|
|
263
273
|
].filter((failure): failure is string => typeof failure === 'string')
|
|
264
274
|
|
|
265
275
|
if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
|
|
@@ -309,6 +319,43 @@ export const skillProvenance: Measure = async (ctx) => {
|
|
|
309
319
|
}
|
|
310
320
|
}
|
|
311
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Every listed practice skill against the closing sections and the ledger
|
|
324
|
+
* `standards/skill-practice.md` requires of one.
|
|
325
|
+
*
|
|
326
|
+
* Read in-process for the reason `skillProvenance` is, so the verb keeps its
|
|
327
|
+
* missing-requirement-only exit. The list names shipped folders alone, so a
|
|
328
|
+
* tree without `claude/skills/` reports unmeasured rather than a vacuous pass.
|
|
329
|
+
*/
|
|
330
|
+
export const skillPracticeShape: Measure = async (ctx) => {
|
|
331
|
+
const report = await auditSkills(ctx.root)
|
|
332
|
+
if (!report.corpora.some((corpus) => corpus.rel === SHIPPED_CORPUS)) {
|
|
333
|
+
return {
|
|
334
|
+
emissions: [],
|
|
335
|
+
unmeasured: `no skill corpus at ${SHIPPED_CORPUS} resolved, so no practice skill was read.`,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const found = report.practiceShape
|
|
340
|
+
if (found.length > 0) {
|
|
341
|
+
const where = found
|
|
342
|
+
.map((finding) => `${finding.rel} ${finding.detail}`)
|
|
343
|
+
.join(', ')
|
|
344
|
+
return {
|
|
345
|
+
emissions: [],
|
|
346
|
+
failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named, or the ledger at ${PRACTICE_LEDGER}.`,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
emissions: [
|
|
352
|
+
info(
|
|
353
|
+
`${plural(PRACTICE_SKILLS.length, 'practice skill')} carry every closing section and a ledger`,
|
|
354
|
+
),
|
|
355
|
+
],
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
312
359
|
/**
|
|
313
360
|
* Every tracked markdown document against the whole-document ceiling.
|
|
314
361
|
*
|
package/src/gate/stages.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
seedStandards,
|
|
15
15
|
rawFieldFileReference,
|
|
16
16
|
shippedReferences,
|
|
17
|
+
skillPracticeShape,
|
|
17
18
|
skillProvenance,
|
|
18
19
|
standardCriteria,
|
|
19
20
|
unreferencedRules,
|
|
@@ -354,7 +355,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
354
355
|
success: 'Context citations resolve',
|
|
355
356
|
},
|
|
356
357
|
{
|
|
357
|
-
// The record's
|
|
358
|
+
// The record's three rules are facts it states about itself, unlike the
|
|
358
359
|
// judgment thresholds the stage above leaves out, so they gate here.
|
|
359
360
|
id: 'architecture-record',
|
|
360
361
|
label: 'Architecture record',
|
|
@@ -486,6 +487,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
486
487
|
label: 'Skill provenance',
|
|
487
488
|
checks: [{ kind: 'measure', measure: skillProvenance }],
|
|
488
489
|
},
|
|
490
|
+
{
|
|
491
|
+
// The audit verb only reports a listed practice skill missing a closing
|
|
492
|
+
// section or its ledger, so a target is told without failing. The list
|
|
493
|
+
// names this repository's shipped skills, so the stage fails here alone.
|
|
494
|
+
id: 'skill-practice-shape',
|
|
495
|
+
label: 'Skill practice shape',
|
|
496
|
+
checks: [{ kind: 'measure', measure: skillPracticeShape }],
|
|
497
|
+
},
|
|
489
498
|
{
|
|
490
499
|
id: 'standard-criteria',
|
|
491
500
|
label: 'Standard success criteria',
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { relative } from 'node:path'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PORT,
|
|
4
|
+
type ServeOutcome,
|
|
5
|
+
type ServeStarted,
|
|
6
|
+
} from '@/serve/static'
|
|
7
|
+
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The relative path where it stays inside the working directory, and the
|
|
11
|
+
* absolute one where it climbs out. A run from a linked worktree serving the
|
|
12
|
+
* main root reports `../../teach`, which names the directory without giving a
|
|
13
|
+
* reader anything they can open.
|
|
14
|
+
*/
|
|
15
|
+
export function displayPath(root: string): string {
|
|
16
|
+
const near = relative(process.cwd(), root)
|
|
17
|
+
if (near === '') return '.'
|
|
18
|
+
return near.startsWith('..') ? root : near
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Undefined for a value that is not a port, distinct from an absent flag,
|
|
23
|
+
* which takes the default. A `Number` of a typo is `NaN`, and passing that on
|
|
24
|
+
* asks the runtime to bind a port nobody named.
|
|
25
|
+
*/
|
|
26
|
+
export function parsePort(raw: string | undefined): number | undefined {
|
|
27
|
+
if (raw === undefined) return DEFAULT_PORT
|
|
28
|
+
const value = Number(raw)
|
|
29
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) return undefined
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The fields a started server reports, shared by every command that serves. */
|
|
34
|
+
export function serveFields(outcome: ServeStarted): {
|
|
35
|
+
readonly host: string
|
|
36
|
+
readonly port: number
|
|
37
|
+
readonly entry: string
|
|
38
|
+
readonly url: string
|
|
39
|
+
readonly entryExists: boolean
|
|
40
|
+
} {
|
|
41
|
+
return {
|
|
42
|
+
host: outcome.host,
|
|
43
|
+
port: outcome.port,
|
|
44
|
+
entry: outcome.entry,
|
|
45
|
+
url: outcome.url,
|
|
46
|
+
entryExists: outcome.entryExists,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The steps a framed report prints for a running server, inside its frame. */
|
|
51
|
+
export function logServing(outcome: ServeStarted): void {
|
|
52
|
+
logStep('Serving')
|
|
53
|
+
logInfo(displayPath(outcome.root))
|
|
54
|
+
logStep('Open')
|
|
55
|
+
logInfo(outcome.url)
|
|
56
|
+
|
|
57
|
+
if (!outcome.entryExists) {
|
|
58
|
+
logStep('No entry page')
|
|
59
|
+
logWarn(
|
|
60
|
+
`${outcome.entry} is not in that directory, so the link opens a 404`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logStep('Stop')
|
|
65
|
+
logInfo('Ctrl-C')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function report(outcome: ServeOutcome, emitJson: boolean): number {
|
|
69
|
+
if (!outcome.ok) {
|
|
70
|
+
if (emitJson) {
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, detail: outcome.detail })}\n`,
|
|
73
|
+
)
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
intro('canon serve')
|
|
77
|
+
logError(outcome.detail)
|
|
78
|
+
outro()
|
|
79
|
+
return 1
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (emitJson) {
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
`${JSON.stringify({ ok: true, root: outcome.root, ...serveFields(outcome) })}\n`,
|
|
85
|
+
)
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
intro('canon serve')
|
|
90
|
+
logServing(outcome)
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves when the process is asked to stop. The frame is closed here rather
|
|
96
|
+
* than in the reporter, because the run is the serving rather than the start,
|
|
97
|
+
* and closing at start would print the frame's end while the server ran on.
|
|
98
|
+
*/
|
|
99
|
+
export function waitForInterrupt(stop: () => Promise<void>): Promise<void> {
|
|
100
|
+
return new Promise((settle) => {
|
|
101
|
+
const finish = () => {
|
|
102
|
+
void stop().then(() => {
|
|
103
|
+
outro()
|
|
104
|
+
settle()
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
process.once('SIGINT', finish)
|
|
108
|
+
process.once('SIGTERM', finish)
|
|
109
|
+
})
|
|
110
|
+
}
|
package/src/teach/nav.ts
CHANGED
|
@@ -1613,8 +1613,19 @@ export async function generateNav(
|
|
|
1613
1613
|
const detail = found.workspace
|
|
1614
1614
|
const metas = await readLessonMetas(root, detail)
|
|
1615
1615
|
|
|
1616
|
+
// The generated base sheet is rewritten on every run so a CLI upgrade
|
|
1617
|
+
// reaches it, while a legacy workspace holding only a hand-authored
|
|
1618
|
+
// course.css is never handed a base sheet its lessons do not link.
|
|
1616
1619
|
const cssPath = join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
1617
|
-
|
|
1620
|
+
const basePath = join(
|
|
1621
|
+
root,
|
|
1622
|
+
detail.path,
|
|
1623
|
+
TEACH_ASSETS,
|
|
1624
|
+
TEACH_STYLESHEET_BASE,
|
|
1625
|
+
)
|
|
1626
|
+
if (existsSync(basePath) || !existsSync(cssPath)) {
|
|
1627
|
+
await writeStylesheet(root, detail.slug)
|
|
1628
|
+
}
|
|
1618
1629
|
|
|
1619
1630
|
const contentsPath = join(root, detail.path, 'index.html')
|
|
1620
1631
|
await writeFile(
|