@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.
- 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/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/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/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 +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/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/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
|
|
|
@@ -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,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
|
|
243
|
-
* line ceiling its allowances derive and the
|
|
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
|
|
249
|
-
*
|
|
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(' ') }
|
package/src/gate/stages.ts
CHANGED
|
@@ -354,7 +354,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
354
354
|
success: 'Context citations resolve',
|
|
355
355
|
},
|
|
356
356
|
{
|
|
357
|
-
// The record's
|
|
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
|
+
}
|