@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.
- package/README.md +1 -0
- 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/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/read-frames/REQUIREMENT.md +1 -0
- package/claude/skills/read-frames/SKILL.md +1 -0
- package/claude/skills/teach-workspace/SKILL.md +4 -4
- 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 +2 -2
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +17 -0
- package/docs/agents/teach-authoring.md +3 -3
- package/docs/agents/teach.md +11 -5
- package/docs/workflow/skill-map.md +11 -10
- 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/commands/context.ts +25 -2
- package/src/commands/pr.ts +63 -41
- 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 +9 -4
- package/src/gate/stages.ts +1 -1
- package/src/serve/report.ts +110 -0
- package/src/teach/nav.ts +12 -1
- package/standards/architecture.md +14 -3
- package/standards/context.md +1 -1
- package/standards/decisions.md +6 -1
- package/standards/groundwork.md +1 -0
- package/standards/requirements.md +1 -1
- package/standards/rule.md +1 -0
- package/standards/skill.md +1 -1
- package/standards/standard.md +1 -0
- package/tooling/go/configs/scripts/verify.sh +47 -0
- package/tooling/go/reference.md +1 -1
- package/tooling/php/configs/scripts/verify.sh +47 -0
- package/tooling/php/reference.md +1 -1
- package/tooling/python/configs/scripts/verify.sh +47 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/web/configs/scripts/verify.sh +47 -0
- package/tooling/web/reference.md +1 -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/audits/catalog.ts
CHANGED
|
@@ -140,7 +140,7 @@ function allOf(
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Reads the architecture record's
|
|
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.',
|
package/src/commands/context.ts
CHANGED
|
@@ -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
|
|
126
|
-
'
|
|
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
|
package/src/commands/pr.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/serve.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
|
-
import {
|
|
3
|
-
import { DEFAULT_PORT,
|
|
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
|
-
}
|
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
|
|