@erclx/canon 5.4.0 → 5.5.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.
Files changed (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/bash-cli-script/REQUIREMENT.md +10 -9
  3. package/claude/skills/bash-cli-script/SKILL.md +10 -7
  4. package/claude/skills/bash-cli-script/references/template.md +4 -28
  5. package/claude/skills/read-frames/REQUIREMENT.md +1 -0
  6. package/claude/skills/read-frames/SKILL.md +1 -0
  7. package/claude/skills/teach-workspace/SKILL.md +4 -4
  8. package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
  9. package/claude/skills/ux-measure/SKILL.md +3 -1
  10. package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
  11. package/claude/skills/ux-walkthrough/SKILL.md +1 -0
  12. package/docs/agents/architecture-record.md +5 -3
  13. package/docs/agents/context-audit-checks.md +1 -1
  14. package/docs/agents/context-audit.md +3 -3
  15. package/docs/agents/index.md +2 -2
  16. package/docs/agents/teach-authoring.md +3 -3
  17. package/docs/agents/teach.md +11 -5
  18. package/docs/workflow/skill-map.md +1 -2
  19. package/governance/rules/lang/120-bash.md +4 -5
  20. package/package.json +1 -1
  21. package/src/audits/catalog.ts +7 -1
  22. package/src/claude/cases/authoring.ts +0 -5
  23. package/src/commands/context.ts +25 -2
  24. package/src/commands/serve.ts +2 -92
  25. package/src/commands/teach.ts +142 -17
  26. package/src/context/architecture.ts +82 -5
  27. package/src/context/gate.ts +10 -3
  28. package/src/gate/measures.ts +9 -4
  29. package/src/gate/stages.ts +1 -1
  30. package/src/serve/report.ts +110 -0
  31. package/src/teach/nav.ts +12 -1
  32. package/standards/architecture.md +14 -3
  33. package/claude/skills/bash-script/REQUIREMENT.md +0 -36
  34. package/claude/skills/bash-script/SKILL.md +0 -100
  35. package/claude/skills/bash-script/references/patterns.md +0 -250
  36. package/claude/skills/bash-script/references/prompts.md +0 -100
@@ -1,7 +1,6 @@
1
1
  import type { Command } from 'commander'
2
- import { relative } from 'node:path'
3
- import { DEFAULT_PORT, type ServeOutcome, startServer } from '@/serve/static'
4
- import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
2
+ import { parsePort, report, waitForInterrupt } from '@/serve/report'
3
+ import { DEFAULT_PORT, startServer } from '@/serve/static'
5
4
 
6
5
  const DEFAULT_DIR = '.'
7
6
 
@@ -78,92 +77,3 @@ async function runServe(
78
77
  await waitForInterrupt(outcome.stop)
79
78
  return 0
80
79
  }
81
-
82
- /**
83
- * The relative path where it stays inside the working directory, and the
84
- * absolute one where it climbs out. A run from a linked worktree serving the
85
- * main root reports `../../teach`, which names the directory without giving a
86
- * reader anything they can open.
87
- */
88
- function displayPath(root: string): string {
89
- const near = relative(process.cwd(), root)
90
- if (near === '') return '.'
91
- return near.startsWith('..') ? root : near
92
- }
93
-
94
- /**
95
- * Undefined for a value that is not a port, distinct from an absent flag,
96
- * which takes the default. A `Number` of a typo is `NaN`, and passing that on
97
- * asks the runtime to bind a port nobody named.
98
- */
99
- function parsePort(raw: string | undefined): number | undefined {
100
- if (raw === undefined) return DEFAULT_PORT
101
- const value = Number(raw)
102
- if (!Number.isInteger(value) || value < 0 || value > 65535) return undefined
103
- return value
104
- }
105
-
106
- function report(outcome: ServeOutcome, emitJson: boolean): number {
107
- if (!outcome.ok) {
108
- if (emitJson) {
109
- process.stdout.write(
110
- `${JSON.stringify({ ok: false, reason: outcome.reason, detail: outcome.detail })}\n`,
111
- )
112
- return 1
113
- }
114
- intro('canon serve')
115
- logError(outcome.detail)
116
- outro()
117
- return 1
118
- }
119
-
120
- if (emitJson) {
121
- process.stdout.write(
122
- `${JSON.stringify({
123
- ok: true,
124
- root: outcome.root,
125
- host: outcome.host,
126
- port: outcome.port,
127
- entry: outcome.entry,
128
- url: outcome.url,
129
- entryExists: outcome.entryExists,
130
- })}\n`,
131
- )
132
- return 0
133
- }
134
-
135
- intro('canon serve')
136
- logStep('Serving')
137
- logInfo(displayPath(outcome.root))
138
- logStep('Open')
139
- logInfo(outcome.url)
140
-
141
- if (!outcome.entryExists) {
142
- logStep('No entry page')
143
- logWarn(
144
- `${outcome.entry} is not in that directory, so the link opens a 404`,
145
- )
146
- }
147
-
148
- logStep('Stop')
149
- logInfo('Ctrl-C')
150
- return 0
151
- }
152
-
153
- /**
154
- * Resolves when the process is asked to stop. The frame is closed here rather
155
- * than in the reporter, because the run is the serving rather than the start,
156
- * and closing at start would print the frame's end while the server ran on.
157
- */
158
- function waitForInterrupt(stop: () => Promise<void>): Promise<void> {
159
- return new Promise((settle) => {
160
- const finish = () => {
161
- void stop().then(() => {
162
- outro()
163
- settle()
164
- })
165
- }
166
- process.once('SIGINT', finish)
167
- process.once('SIGTERM', finish)
168
- })
169
- }
@@ -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
- 'Opening a workspace:',
100
- ' canon serve .canon/teach --entry <nn>-<topic>/index.html',
112
+ 'Viewing a workspace:',
113
+ ' canon teach up <topic>',
101
114
  '',
102
- 'canon teach list <topic> prints the exact line, with the teach folder',
103
- 'written from where you stand rather than as this literal.',
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
- outro()
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: { heading: string; line: number; body: string[] } | undefined
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
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
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
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
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
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
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
  *
@@ -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: the record states
77
- * each limit for itself, which makes those the measures here that are facts
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)
@@ -11,6 +11,7 @@ import {
11
11
  isOverCount,
12
12
  isOverLength,
13
13
  measureArchitecture,
14
+ missingRevisit,
14
15
  } from '@/context/architecture'
15
16
  import { auditSkills } from '@/claude/skills-audit'
16
17
  import { listRepositoryFiles } from '@/git-files'
@@ -239,14 +240,15 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
239
240
  }
240
241
 
241
242
  /**
242
- * The architecture record against the two limits it states for itself: the
243
- * line ceiling its allowances derive and the entry cap.
243
+ * The architecture record against the three rules it states for itself: the
244
+ * line ceiling its allowances derive, the entry cap, and the revisit sentence
245
+ * closing each decision.
244
246
  *
245
247
  * Read in-process rather than through `context audit`, whose one gating stage
246
248
  * here runs `--citations-only` and never opens the record, which is why the
247
249
  * line ceiling went unenforced by `bun run check` until this stage. A project
248
- * carrying no record, or a record stating neither limit, passes, since both
249
- * limits belong to the record rather than to the toolkit.
250
+ * carrying no record, or a record stating none of the three, passes, since
251
+ * each rule belongs to the record rather than to the toolkit.
250
252
  */
251
253
  export const architectureRecord: Measure = async (ctx) => {
252
254
  const report = await measureArchitecture(ctx.root)
@@ -255,11 +257,14 @@ export const architectureRecord: Measure = async (ctx) => {
255
257
  }
256
258
 
257
259
  const decisions = report.decisions.length
260
+ const lackingRevisit = missingRevisit(report)
258
261
  const failures = [
259
262
  isOverCount(report) &&
260
263
  `${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
261
264
  isOverLength(report) &&
262
265
  `${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
266
+ lackingRevisit.length > 0 &&
267
+ `No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
263
268
  ].filter((failure): failure is string => typeof failure === 'string')
264
269
 
265
270
  if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
@@ -354,7 +354,7 @@ export const STAGES: readonly Stage[] = [
354
354
  success: 'Context citations resolve',
355
355
  },
356
356
  {
357
- // The record's two limits are facts it states about itself, unlike the
357
+ // The record's three rules are facts it states about itself, unlike the
358
358
  // judgment thresholds the stage above leaves out, so they gate here.
359
359
  id: 'architecture-record',
360
360
  label: 'Architecture record',
@@ -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
+ }