@erclx/canon 5.3.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 (56) hide show
  1. package/README.md +1 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/bash-cli-script/REQUIREMENT.md +10 -9
  4. package/claude/skills/bash-cli-script/SKILL.md +10 -7
  5. package/claude/skills/bash-cli-script/references/template.md +4 -28
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/read-frames/REQUIREMENT.md +1 -0
  8. package/claude/skills/read-frames/SKILL.md +1 -0
  9. package/claude/skills/teach-workspace/SKILL.md +4 -4
  10. package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
  11. package/claude/skills/ux-measure/SKILL.md +3 -1
  12. package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
  13. package/claude/skills/ux-walkthrough/SKILL.md +1 -0
  14. package/docs/agents/architecture-record.md +5 -3
  15. package/docs/agents/context-audit-checks.md +1 -1
  16. package/docs/agents/context-audit.md +3 -3
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/markdown-audit.md +1 -1
  19. package/docs/agents/pr-evidence.md +17 -0
  20. package/docs/agents/teach-authoring.md +3 -3
  21. package/docs/agents/teach.md +11 -5
  22. package/docs/workflow/skill-map.md +11 -10
  23. package/governance/rules/lang/120-bash.md +4 -5
  24. package/package.json +1 -1
  25. package/src/audits/catalog.ts +7 -1
  26. package/src/claude/cases/authoring.ts +0 -5
  27. package/src/commands/context.ts +25 -2
  28. package/src/commands/pr.ts +63 -41
  29. package/src/commands/serve.ts +2 -92
  30. package/src/commands/teach.ts +142 -17
  31. package/src/context/architecture.ts +82 -5
  32. package/src/context/gate.ts +10 -3
  33. package/src/gate/measures.ts +9 -4
  34. package/src/gate/stages.ts +1 -1
  35. package/src/serve/report.ts +110 -0
  36. package/src/teach/nav.ts +12 -1
  37. package/standards/architecture.md +14 -3
  38. package/standards/context.md +1 -1
  39. package/standards/decisions.md +6 -1
  40. package/standards/groundwork.md +1 -0
  41. package/standards/requirements.md +1 -1
  42. package/standards/rule.md +1 -0
  43. package/standards/skill.md +1 -1
  44. package/standards/standard.md +1 -0
  45. package/tooling/go/configs/scripts/verify.sh +47 -0
  46. package/tooling/go/reference.md +1 -1
  47. package/tooling/php/configs/scripts/verify.sh +47 -0
  48. package/tooling/php/reference.md +1 -1
  49. package/tooling/python/configs/scripts/verify.sh +47 -0
  50. package/tooling/python/reference.md +1 -1
  51. package/tooling/web/configs/scripts/verify.sh +47 -0
  52. package/tooling/web/reference.md +1 -1
  53. package/claude/skills/bash-script/REQUIREMENT.md +0 -36
  54. package/claude/skills/bash-script/SKILL.md +0 -100
  55. package/claude/skills/bash-script/references/patterns.md +0 -250
  56. package/claude/skills/bash-script/references/prompts.md +0 -100
@@ -140,7 +140,7 @@ function allOf(
140
140
  }
141
141
 
142
142
  /**
143
- * Reads the architecture record's three measures, or nothing when the project
143
+ * Reads the architecture record's measures, or nothing when the project
144
144
  * carries no record.
145
145
  *
146
146
  * Three states rather than two, matching what the verb publishes. The key
@@ -172,8 +172,11 @@ function architectureCounts(
172
172
  const entryCap =
173
173
  typeof record.entryCap === 'number' ? record.entryCap : undefined
174
174
 
175
+ const revisitRequired = record.revisitRequired === true
176
+
175
177
  let unverifiable = 0
176
178
  let unchecked = 0
179
+ let lackingRevisit = 0
177
180
  for (const raw of decisions) {
178
181
  const entry = asObject(raw)
179
182
  const claim = entry?.claim
@@ -182,6 +185,7 @@ function architectureCounts(
182
185
 
183
186
  if (claim === 'neither') unverifiable += 1
184
187
  else if (checks === 0) unchecked += 1
188
+ if (revisitRequired && entry?.revisit !== true) lackingRevisit += 1
185
189
  }
186
190
 
187
191
  return {
@@ -195,6 +199,8 @@ function architectureCounts(
195
199
  ...(entryCap !== undefined && {
196
200
  recordOverCount: decisions.length > entryCap ? 1 : 0,
197
201
  }),
202
+ // Absent on a record stating no revisit clause, for the same reason.
203
+ ...(revisitRequired && { recordMissingRevisit: lackingRevisit }),
198
204
  recordUnverifiable: unverifiable,
199
205
  recordUnchecked: unchecked,
200
206
  }
@@ -5,11 +5,6 @@ import type { SkillCase } from '@/claude/skills-rank'
5
5
  * skills that revise rather than generate.
6
6
  */
7
7
  export const AUTHORING_CASES: readonly SkillCase[] = [
8
- {
9
- prompt:
10
- 'I need a shell tool with prompts and a nice terminal UI for people to run by hand.',
11
- expect: 'bash-script',
12
- },
13
8
  {
14
9
  prompt:
15
10
  'Wire up a GitHub Actions pipeline with parallel jobs for this repo.',
@@ -25,6 +25,7 @@ import {
25
25
  isOverCount,
26
26
  isOverLength,
27
27
  measureArchitecture,
28
+ missingRevisit,
28
29
  testableCount,
29
30
  } from '@/context/architecture'
30
31
  import { auditCitations, type CitationReport } from '@/context/citations'
@@ -122,8 +123,10 @@ export function register(program: Command): void {
122
123
  'states its own line allowances gates when it is past the ceiling',
123
124
  'those derive, on any run except --citations-only, which never',
124
125
  'measures it. A record stating an entry cap gates the same way when',
125
- 'it holds more decisions than the cap. A record stating neither is',
126
- 'reported and never gated. --gate widens the gate to the other two',
126
+ 'it holds more decisions than the cap, and a record stating that',
127
+ 'every decision closes with a revisit sentence gates when one lacks',
128
+ 'it. A record stating none of the three is reported and never',
129
+ 'gated. --gate widens the gate to the other two',
127
130
  'findings that are facts rather than judgments: a missing required',
128
131
  'section and index drift. A context entry requires Overview and',
129
132
  'Layout, and a wireframe requires Regions, States, Copy, and Not on',
@@ -739,6 +742,8 @@ async function runAudit(
739
742
  unresolvedCitations: citations.unresolved.length,
740
743
  recordOverLength: record !== undefined && isOverLength(record),
741
744
  recordOverCount: record !== undefined && isOverCount(record),
745
+ recordMissingRevisit:
746
+ record !== undefined && missingRevisit(record).length > 0,
742
747
  sections,
743
748
  drift,
744
749
  wireframes,
@@ -1194,6 +1199,24 @@ function reportRecord(
1194
1199
  )
1195
1200
  }
1196
1201
 
1202
+ const revisited = report.decisions.filter((entry) => entry.revisit).length
1203
+ const lackingRevisit = missingRevisit(report)
1204
+ if (!report.revisitRequired) {
1205
+ logInfo(
1206
+ `${revisited} of ${plural(decisions, 'decision')} close with a revisit sentence, and the record states no revisit clause, so this is reported and never gated.`,
1207
+ )
1208
+ } else if (lackingRevisit.length > 0) {
1209
+ for (const heading of lackingRevisit) {
1210
+ logError(
1211
+ `"${heading}" carries no sentence opening "Revisit when", which the record requires of every decision.`,
1212
+ )
1213
+ }
1214
+ } else {
1215
+ logInfo(
1216
+ `Every one of ${plural(decisions, 'decision')} closes with the revisit sentence the record requires.`,
1217
+ )
1218
+ }
1219
+
1197
1220
  if (decisions === 0) {
1198
1221
  logWarn('The record declares no decision, so nothing was classified.')
1199
1222
  return
@@ -462,6 +462,16 @@ export function register(program: Command): void {
462
462
  'comment. Together with --checklist it does, so the checklist and the',
463
463
  'link land in one comment.',
464
464
  '',
465
+ 'Both reasons carry what the marked comment already posted, each field',
466
+ 'present only when that comment holds it:',
467
+ ' preview the hosted **Preview:** address the comment opens with',
468
+ ' local the **Local preview:** address under it',
469
+ ' checklist the checklist between its delimiters, ticks included',
470
+ 'These come from the comment already posted, never from the flags this',
471
+ 'call passed. A checklist posted on its own carries no marker, so the',
472
+ 'record reports none of the three for it. An unread thread refuses as',
473
+ 'gh-failed on both reasons rather than reporting the fields absent.',
474
+ '',
465
475
  'Exit codes:',
466
476
  ' 0 read, whether it produced a body or reported no-evidence',
467
477
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -1424,47 +1434,6 @@ async function runEvidence(
1424
1434
  baseTree.has(path),
1425
1435
  )
1426
1436
 
1427
- // A local address rides on a checklist or a hosted preview and never opens
1428
- // a comment alone, so a docs-only branch with a server up posts nothing.
1429
- const hasChecklistAndLocal =
1430
- suppliedChecklist !== undefined && opts.local !== undefined
1431
- if (
1432
- grouped.kind === 'refused' &&
1433
- opts.preview === undefined &&
1434
- !hasChecklistAndLocal
1435
- ) {
1436
- logStep('Skipped')
1437
- logInfo(
1438
- 'No changed path carries an evidence/ segment, so there is nothing to post.',
1439
- )
1440
- outro()
1441
- if (emitJson) {
1442
- process.stdout.write(
1443
- `${JSON.stringify({
1444
- root,
1445
- ...(identity.number !== undefined && { number: identity.number }),
1446
- reason: 'no-evidence',
1447
- })}\n`,
1448
- )
1449
- }
1450
- return 0
1451
- }
1452
-
1453
- const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1454
- const repo =
1455
- repoRow === null
1456
- ? undefined
1457
- : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1458
-
1459
- if (repo === undefined) {
1460
- return refuseWith(
1461
- 'gh-failed',
1462
- EVIDENCE_REFUSALS['gh-failed'],
1463
- emitJson,
1464
- root,
1465
- )
1466
- }
1467
-
1468
1437
  let commentId: number | undefined
1469
1438
  let carriedPreview: string | undefined
1470
1439
  let carriedChecklist: string | undefined
@@ -1480,6 +1449,8 @@ async function runEvidence(
1480
1449
  // An unread thread refuses rather than rendering, since a body built
1481
1450
  // without it knows neither the comment to edit nor the preview address
1482
1451
  // to carry, and posting it would duplicate the comment and drop the link.
1452
+ // It refuses ahead of no-evidence too, so that record never reports an
1453
+ // absent field it did not read.
1483
1454
  let comments: readonly { url?: string; body: string }[] | undefined
1484
1455
  if (commentsRow !== null) {
1485
1456
  try {
@@ -1506,6 +1477,56 @@ async function runEvidence(
1506
1477
  carriedLocal = findEvidenceLocal(comments)
1507
1478
  }
1508
1479
 
1480
+ // What the marked comment already shows a reviewer, kept apart from the
1481
+ // flags this call passed so a reader learns what is posted.
1482
+ const carried = {
1483
+ ...(carriedPreview !== undefined && { preview: carriedPreview }),
1484
+ ...(carriedLocal !== undefined && { local: carriedLocal }),
1485
+ ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1486
+ }
1487
+
1488
+ // A local address rides on a checklist or a hosted preview and never opens
1489
+ // a comment alone, so a docs-only branch with a server up posts nothing.
1490
+ const hasChecklistAndLocal =
1491
+ suppliedChecklist !== undefined && opts.local !== undefined
1492
+ if (
1493
+ grouped.kind === 'refused' &&
1494
+ opts.preview === undefined &&
1495
+ !hasChecklistAndLocal
1496
+ ) {
1497
+ logStep('Skipped')
1498
+ logInfo(
1499
+ 'No changed path carries an evidence/ segment, so there is nothing to post.',
1500
+ )
1501
+ outro()
1502
+ if (emitJson) {
1503
+ process.stdout.write(
1504
+ `${JSON.stringify({
1505
+ root,
1506
+ ...(identity.number !== undefined && { number: identity.number }),
1507
+ reason: 'no-evidence',
1508
+ ...carried,
1509
+ })}\n`,
1510
+ )
1511
+ }
1512
+ return 0
1513
+ }
1514
+
1515
+ const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1516
+ const repo =
1517
+ repoRow === null
1518
+ ? undefined
1519
+ : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1520
+
1521
+ if (repo === undefined) {
1522
+ return refuseWith(
1523
+ 'gh-failed',
1524
+ EVIDENCE_REFUSALS['gh-failed'],
1525
+ emitJson,
1526
+ root,
1527
+ )
1528
+ }
1529
+
1509
1530
  const preview = opts.preview ?? carriedPreview
1510
1531
  const local = opts.local ?? carriedLocal
1511
1532
  const checklist = suppliedChecklist ?? carriedChecklist
@@ -1548,6 +1569,7 @@ async function runEvidence(
1548
1569
  head: identity.head,
1549
1570
  body,
1550
1571
  ...(commentId !== undefined && { commentId }),
1572
+ ...carried,
1551
1573
  })}\n`,
1552
1574
  )
1553
1575
  }
@@ -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