@erclx/canon 4.68.0 → 4.69.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/docs/agents/commands.md +1 -0
- package/docs/agents/sandbox.md +13 -10
- package/package.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +8 -0
- package/src/claude/plugin-update.ts +48 -0
- package/src/commands/claude.ts +281 -1
- package/src/commands/feedback.ts +15 -5
- package/src/commands/gate.ts +3 -1
- package/src/commands/sandbox.ts +13 -4
- package/src/design/components.ts +12 -0
- package/src/gate/measures.ts +47 -1
- package/src/sandbox/expect.ts +26 -1
- package/src/teach/nav.ts +97 -3
- package/standards/glossary.md +8 -0
package/docs/agents/commands.md
CHANGED
|
@@ -68,6 +68,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
68
68
|
| `canon claude skills reach` | Report the bodies in either skill corpus citing a toolkit path no target project receives, exiting 2 on an unqualified one |
|
|
69
69
|
| `canon claude skills rank` | Score either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`) |
|
|
70
70
|
| `canon claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
|
|
71
|
+
| `canon claude plugin-update` | Match the installed marketplace plugin against `claude/.claude-plugin/plugin.json`'s own name and run `claude plugin update` on it, reading the version back off `claude plugin list --json` since the update call reports none of its own (`--json`) |
|
|
71
72
|
| `canon gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
72
73
|
| `canon gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value and on the family stem behind a templated citation (`--json`) |
|
|
73
74
|
| `canon gov restated` | Report every instruction the always-loaded file or a rule shares with the seed, a shipped skill body, or another rule, classed and with its anchors named (`--json`) |
|
package/docs/agents/sandbox.md
CHANGED
|
@@ -22,17 +22,18 @@ Scenario categories: `infra:*` (domain flows), `git:*`, `scaffold:*`. `create` s
|
|
|
22
22
|
`canon sandbox check <category>:<command> [arm]` scores a provisioned sandbox against the arm's `expect.toml`, printing a verdict on stderr and, with `--json`, the same verdict as a record on stdout.
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
canon sandbox check claude:docs drift --json
|
|
25
|
+
canon sandbox check claude:docs-fold drift --json
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
| Flag
|
|
29
|
-
|
|
|
30
|
-
| `--envelope <file>`
|
|
31
|
-
| `--writes <file>`
|
|
32
|
-
| `--escapes <file>`
|
|
33
|
-
| `--escapes-watched`
|
|
34
|
-
| `--
|
|
35
|
-
| `--
|
|
28
|
+
| Flag | Effect |
|
|
29
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
30
|
+
| `--envelope <file>` | Read `is_error`, `num_turns`, denials, and the reply text |
|
|
31
|
+
| `--writes <file>` | Newline-delimited paths the session wrote, for write scope |
|
|
32
|
+
| `--escapes <file>` | Newline-delimited paths written to a watched toolkit root, for escape scope |
|
|
33
|
+
| `--escapes-watched` | At least one watched root held a target this run |
|
|
34
|
+
| `--concurrent-sessions <file>` | Newline-delimited sessions live in the registry both before and after this run, a witness for an unbounded escape |
|
|
35
|
+
| `--json` | Emit the verdict record on stdout |
|
|
36
|
+
| `--strict` | Exit 1 on `unchecked` instead of 0 |
|
|
36
37
|
|
|
37
38
|
The verdict `state` is `pass`, `fail`, or `unchecked`. An arm with no `expect.toml` is `unchecked` and exits 0, so the harness stays usable while expectations roll out. A declaration that exists but asserts nothing is a failure, since an expectation file that asserts nothing passes every run.
|
|
38
39
|
|
|
@@ -70,4 +71,6 @@ A skill pairs to a scenario by filename, `<category>-<command>` first and bare `
|
|
|
70
71
|
|
|
71
72
|
`scripts/sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.canon/tmp/sandbox-runs/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit.
|
|
72
73
|
|
|
73
|
-
Two more fields ride alongside the verdict rather than inside it. `escapes` lists what the run wrote under a watched toolkit root, which the verdict cannot assert over because those files sit outside the sandbox tree. `sessions` reports the nested-dispatch bound, carrying `watched` for whether the client's session registry was there to read, `new` for the records that appeared while the run was in flight, and `reap` for what the run found in the session's process group afterwards. Neither field fails a run on its own.
|
|
74
|
+
Two more fields ride alongside the verdict rather than inside it. `escapes` lists what the run wrote under a watched toolkit root, which the verdict cannot assert over because those files sit outside the sandbox tree. `sessions` reports the nested-dispatch bound, carrying `watched` for whether the client's session registry was there to read, `new` for the records that appeared while the run was in flight, `concurrent` for the records present both before and after, and `reap` for what the run found in the session's process group afterwards. Neither field fails a run on its own.
|
|
75
|
+
|
|
76
|
+
`run.sh` passes `concurrent` through `--concurrent-sessions` to `sandbox check`, and `checkEscapeScope` appends a witness count to an `unbounded escape:` message rather than lets it soften the verdict.
|
package/package.json
CHANGED
|
@@ -162,6 +162,14 @@ sessions_between() {
|
|
|
162
162
|
comm -13 "$1" "$2"
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
# The side `sessions_between` drops. A record present on both sides is a session
|
|
166
|
+
# that was already running before this run started and still was after, which
|
|
167
|
+
# rules it out as this run's own dispatch and rules it in as a witness able to
|
|
168
|
+
# explain a write the escape watch attributes to nobody.
|
|
169
|
+
sessions_concurrent() {
|
|
170
|
+
comm -12 "$1" "$2"
|
|
171
|
+
}
|
|
172
|
+
|
|
165
173
|
# Turns a record name into a line a person can act on. The file can be gone by
|
|
166
174
|
# the time this reads it, since a session that started and exited inside the run
|
|
167
175
|
# takes its record with it, so an unreadable record still reports its name rather
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** One row of `claude plugin list --json`, the fields this module reads. */
|
|
2
|
+
export interface PluginInstall {
|
|
3
|
+
readonly id: string
|
|
4
|
+
readonly version: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The plugin's own name, out of a `plugin.json` buffer. `undefined` on
|
|
9
|
+
* anything that fails to parse or carries no string `name`, so a caller
|
|
10
|
+
* refuses rather than matching against `undefined@`.
|
|
11
|
+
*/
|
|
12
|
+
export function readPluginName(manifest: string): string | undefined {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(manifest) as { name?: unknown }
|
|
15
|
+
return typeof parsed.name === 'string' ? parsed.name : undefined
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MatchResult =
|
|
22
|
+
| { readonly kind: 'matched'; readonly install: PluginInstall }
|
|
23
|
+
| { readonly kind: 'none' }
|
|
24
|
+
| { readonly kind: 'many'; readonly installs: readonly PluginInstall[] }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `id` is `<name>@<marketplace>`, so a name alone can match more than one row
|
|
28
|
+
* when the same plugin is installed from two marketplaces at once. Refusing
|
|
29
|
+
* that case rather than picking a row is the same move `readPluginName`
|
|
30
|
+
* makes: nothing here guesses at an ambiguity the caller can see plainly.
|
|
31
|
+
*/
|
|
32
|
+
export function matchInstall(
|
|
33
|
+
name: string,
|
|
34
|
+
installs: readonly PluginInstall[],
|
|
35
|
+
): MatchResult {
|
|
36
|
+
const prefix = `${name}@`
|
|
37
|
+
const matches = installs.filter((install) => install.id.startsWith(prefix))
|
|
38
|
+
|
|
39
|
+
if (matches.length === 0) return { kind: 'none' }
|
|
40
|
+
if (matches.length > 1) return { kind: 'many', installs: matches }
|
|
41
|
+
return { kind: 'matched', install: matches[0] as PluginInstall }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updatedMessage(before: string, after: string): string {
|
|
45
|
+
return after === before
|
|
46
|
+
? `Reinstalled ${after}, unchanged.`
|
|
47
|
+
: `Updated ${before} to ${after}.`
|
|
48
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -3,7 +3,16 @@ import { chmod, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import type { Command } from 'commander'
|
|
6
|
+
import { execa } from 'execa'
|
|
7
|
+
import { singleLine } from '@/commands/upgrade'
|
|
8
|
+
import { gitEnv } from '@/git-env'
|
|
6
9
|
import { claudeChain, pendingEntries, planGitignore } from '@/claude/gitignore'
|
|
10
|
+
import {
|
|
11
|
+
matchInstall,
|
|
12
|
+
type PluginInstall,
|
|
13
|
+
readPluginName,
|
|
14
|
+
updatedMessage,
|
|
15
|
+
} from '@/claude/plugin-update'
|
|
7
16
|
import {
|
|
8
17
|
applySeeds,
|
|
9
18
|
countByScope,
|
|
@@ -90,6 +99,37 @@ interface SkillsDriftOptions {
|
|
|
90
99
|
readonly json?: boolean
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
interface PluginUpdateOptions {
|
|
103
|
+
readonly json?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `no-claude` and `no-plugin` name permanent conditions on a machine that
|
|
108
|
+
* never carries the marketplace plugin at all, matching the `gh-missing` and
|
|
109
|
+
* `no-repository` reasons `.husky/post-merge`'s other two steps already stay
|
|
110
|
+
* quiet on forever rather than nagging a project that will never fix them.
|
|
111
|
+
* Every other reason is a real defect and prints.
|
|
112
|
+
*/
|
|
113
|
+
type PluginUpdateReason =
|
|
114
|
+
| 'no-claude'
|
|
115
|
+
| 'no-manifest'
|
|
116
|
+
| 'no-name'
|
|
117
|
+
| 'list-failed'
|
|
118
|
+
| 'no-plugin'
|
|
119
|
+
| 'ambiguous'
|
|
120
|
+
| 'update-failed'
|
|
121
|
+
| 'after-list-failed'
|
|
122
|
+
|
|
123
|
+
interface PluginUpdateRecord {
|
|
124
|
+
readonly root: string
|
|
125
|
+
readonly id?: string
|
|
126
|
+
readonly before?: string
|
|
127
|
+
readonly after?: string
|
|
128
|
+
readonly state: 'updated' | 'current' | 'refused'
|
|
129
|
+
readonly reason?: PluginUpdateReason
|
|
130
|
+
readonly message: string
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
interface SkillsReachOptions {
|
|
94
134
|
readonly json?: boolean
|
|
95
135
|
}
|
|
@@ -111,11 +151,24 @@ const SEEDED_FILES: readonly string[] = [
|
|
|
111
151
|
const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
|
|
112
152
|
const USER_DIR = join('tooling', 'claude', 'user')
|
|
113
153
|
const STATUSLINE = 'statusline-command.sh'
|
|
154
|
+
const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
|
|
155
|
+
|
|
156
|
+
/** A local cache read, not a network round trip, matching reclaim.ts's own bound for `claude agents --json`. */
|
|
157
|
+
const PLUGIN_LIST_TIMEOUT_MS = 10_000
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `claude plugin update` fetches a marketplace archive over the network, so a
|
|
161
|
+
* stalled fetch should not hang the caller, which is a git hook on the
|
|
162
|
+
* ordinary path.
|
|
163
|
+
*/
|
|
164
|
+
const PLUGIN_UPDATE_TIMEOUT_MS = 60_000
|
|
114
165
|
|
|
115
166
|
export function register(program: Command): void {
|
|
116
167
|
const claude = program
|
|
117
168
|
.command('claude')
|
|
118
|
-
.description(
|
|
169
|
+
.description(
|
|
170
|
+
'Claude workflow (init, seeds, sync, setup, routing, plugin-update)',
|
|
171
|
+
)
|
|
119
172
|
.helpOption('-h, --help', 'Show this help message')
|
|
120
173
|
.addHelpText(
|
|
121
174
|
'after',
|
|
@@ -227,6 +280,39 @@ export function register(program: Command): void {
|
|
|
227
280
|
process.exitCode = runRouting(path, opts)
|
|
228
281
|
})
|
|
229
282
|
|
|
283
|
+
claude
|
|
284
|
+
.command('plugin-update')
|
|
285
|
+
.description(
|
|
286
|
+
'Update the installed marketplace plugin cache to match this CLI',
|
|
287
|
+
)
|
|
288
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
289
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
290
|
+
.addHelpText(
|
|
291
|
+
'after',
|
|
292
|
+
[
|
|
293
|
+
'',
|
|
294
|
+
'Mechanics:',
|
|
295
|
+
' Reads the plugin name out of claude/.claude-plugin/plugin.json,',
|
|
296
|
+
' matches it against an installed row from `claude plugin list',
|
|
297
|
+
' --json` by id prefix (<name>@), then runs `claude plugin update',
|
|
298
|
+
' <id> -y`. There is no --json on the update call itself, so the',
|
|
299
|
+
' version is read back off `claude plugin list --json` again and',
|
|
300
|
+
' compared to what it was before.',
|
|
301
|
+
'',
|
|
302
|
+
'Exit codes:',
|
|
303
|
+
' 0 current already, or the update ran',
|
|
304
|
+
' 1 refused, with the reason on stderr',
|
|
305
|
+
'',
|
|
306
|
+
'Examples:',
|
|
307
|
+
' canon claude plugin-update',
|
|
308
|
+
' canon claude plugin-update --json',
|
|
309
|
+
'',
|
|
310
|
+
].join('\n'),
|
|
311
|
+
)
|
|
312
|
+
.action(async (opts: PluginUpdateOptions) => {
|
|
313
|
+
process.exitCode = await runPluginUpdate(opts)
|
|
314
|
+
})
|
|
315
|
+
|
|
230
316
|
const skills = claude
|
|
231
317
|
.command('skills')
|
|
232
318
|
.description('Plugin skill catalog (list, audit, drift, reach, rank)')
|
|
@@ -830,6 +916,200 @@ function reportRouting(
|
|
|
830
916
|
)
|
|
831
917
|
}
|
|
832
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Reads the plugin name off the manifest, matches it against an installed row,
|
|
921
|
+
* runs the update, and reads the version back off the same list rather than
|
|
922
|
+
* trusting the update call's own report, since `claude plugin update` carries
|
|
923
|
+
* no `--json` to answer with. `canon upgrade` makes the identical move for the
|
|
924
|
+
* package managers it drives.
|
|
925
|
+
*/
|
|
926
|
+
async function runPluginUpdate(opts: PluginUpdateOptions): Promise<number> {
|
|
927
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
928
|
+
intro('canon claude plugin-update')
|
|
929
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
930
|
+
|
|
931
|
+
const manifestPath = join(PROJECT_ROOT, PLUGIN_MANIFEST)
|
|
932
|
+
let manifestText: string
|
|
933
|
+
try {
|
|
934
|
+
manifestText = await readFile(manifestPath, 'utf8')
|
|
935
|
+
} catch {
|
|
936
|
+
return refusePluginUpdate(
|
|
937
|
+
opts,
|
|
938
|
+
'no-manifest',
|
|
939
|
+
`No manifest at ${manifestPath}, so there is no plugin name to update.`,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const name = readPluginName(manifestText)
|
|
944
|
+
if (name === undefined) {
|
|
945
|
+
return refusePluginUpdate(
|
|
946
|
+
opts,
|
|
947
|
+
'no-name',
|
|
948
|
+
`No name field in ${manifestPath}, so there is nothing to match against an installed plugin.`,
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
logStep('Manifest')
|
|
953
|
+
logInfo(`${name}, from ${manifestPath}`)
|
|
954
|
+
|
|
955
|
+
const before = await listPluginInstalls()
|
|
956
|
+
if (before.kind === 'missing') {
|
|
957
|
+
return refusePluginUpdate(
|
|
958
|
+
opts,
|
|
959
|
+
'no-claude',
|
|
960
|
+
'claude is not on PATH, so no installed plugin could be read.',
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
if (before.kind === 'failed') {
|
|
964
|
+
return refusePluginUpdate(
|
|
965
|
+
opts,
|
|
966
|
+
'list-failed',
|
|
967
|
+
`\`claude plugin list --json\` failed. ${before.detail}`,
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const match = matchInstall(name, before.installs)
|
|
972
|
+
if (match.kind === 'none') {
|
|
973
|
+
return refusePluginUpdate(
|
|
974
|
+
opts,
|
|
975
|
+
'no-plugin',
|
|
976
|
+
`No installed plugin carries the id prefix "${name}@". Install it with \`claude plugin install\` first.`,
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
if (match.kind === 'many') {
|
|
980
|
+
return refusePluginUpdate(
|
|
981
|
+
opts,
|
|
982
|
+
'ambiguous',
|
|
983
|
+
`Multiple installed plugins share the name "${name}": ${match.installs
|
|
984
|
+
.map((install) => install.id)
|
|
985
|
+
.join(', ')}. Refusing rather than picking one.`,
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const { install } = match
|
|
990
|
+
logStep('Installed')
|
|
991
|
+
logInfo(`${install.id} ${install.version}`)
|
|
992
|
+
|
|
993
|
+
logStep('Updating')
|
|
994
|
+
const result = await execa('claude', ['plugin', 'update', install.id, '-y'], {
|
|
995
|
+
reject: false,
|
|
996
|
+
timeout: PLUGIN_UPDATE_TIMEOUT_MS,
|
|
997
|
+
env: gitEnv(),
|
|
998
|
+
extendEnv: false,
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
if (result.exitCode !== 0) {
|
|
1002
|
+
return refusePluginUpdate(
|
|
1003
|
+
opts,
|
|
1004
|
+
'update-failed',
|
|
1005
|
+
`\`claude plugin update ${install.id} -y\` exited ${result.exitCode}. ${(result.stderr || result.stdout).trim()}`,
|
|
1006
|
+
install.id,
|
|
1007
|
+
install.version,
|
|
1008
|
+
)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const after = await listPluginInstalls()
|
|
1012
|
+
if (after.kind !== 'ok') {
|
|
1013
|
+
return refusePluginUpdate(
|
|
1014
|
+
opts,
|
|
1015
|
+
'after-list-failed',
|
|
1016
|
+
'`claude plugin update` ran, but `claude plugin list --json` failed to read the version back.',
|
|
1017
|
+
install.id,
|
|
1018
|
+
install.version,
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const afterVersion =
|
|
1023
|
+
after.installs.find((row) => row.id === install.id)?.version ??
|
|
1024
|
+
install.version
|
|
1025
|
+
const state = afterVersion === install.version ? 'current' : 'updated'
|
|
1026
|
+
|
|
1027
|
+
logStep('Installed')
|
|
1028
|
+
logInfo(
|
|
1029
|
+
afterVersion === install.version
|
|
1030
|
+
? `${afterVersion}, unchanged`
|
|
1031
|
+
: `${install.version} to ${afterVersion}`,
|
|
1032
|
+
)
|
|
1033
|
+
outro()
|
|
1034
|
+
|
|
1035
|
+
emitPluginUpdate(opts, {
|
|
1036
|
+
root: PROJECT_ROOT,
|
|
1037
|
+
id: install.id,
|
|
1038
|
+
before: install.version,
|
|
1039
|
+
after: afterVersion,
|
|
1040
|
+
state,
|
|
1041
|
+
message: updatedMessage(install.version, afterVersion),
|
|
1042
|
+
})
|
|
1043
|
+
return 0
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
type ListInstallsResult =
|
|
1047
|
+
| { readonly kind: 'ok'; readonly installs: readonly PluginInstall[] }
|
|
1048
|
+
| { readonly kind: 'missing' }
|
|
1049
|
+
| { readonly kind: 'failed'; readonly detail: string }
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* `missing` separates a `claude` binary that is not on PATH from every other
|
|
1053
|
+
* failure, since only that condition is permanent enough for the hook to
|
|
1054
|
+
* silence forever. execa reports it as `ENOENT` on the result rather than by
|
|
1055
|
+
* throwing, because the call runs with `reject: false`.
|
|
1056
|
+
*/
|
|
1057
|
+
async function listPluginInstalls(): Promise<ListInstallsResult> {
|
|
1058
|
+
const result = await execa('claude', ['plugin', 'list', '--json'], {
|
|
1059
|
+
reject: false,
|
|
1060
|
+
timeout: PLUGIN_LIST_TIMEOUT_MS,
|
|
1061
|
+
env: gitEnv(),
|
|
1062
|
+
extendEnv: false,
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
if (result.code === 'ENOENT') return { kind: 'missing' }
|
|
1066
|
+
if (result.exitCode !== 0) {
|
|
1067
|
+
return { kind: 'failed', detail: (result.stderr || result.stdout).trim() }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
return {
|
|
1072
|
+
kind: 'ok',
|
|
1073
|
+
installs: JSON.parse(result.stdout) as readonly PluginInstall[],
|
|
1074
|
+
}
|
|
1075
|
+
} catch {
|
|
1076
|
+
return {
|
|
1077
|
+
kind: 'failed',
|
|
1078
|
+
detail: '`claude plugin list --json` did not print valid JSON.',
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function refusePluginUpdate(
|
|
1084
|
+
opts: PluginUpdateOptions,
|
|
1085
|
+
reason: PluginUpdateReason,
|
|
1086
|
+
message: string,
|
|
1087
|
+
id?: string,
|
|
1088
|
+
before?: string,
|
|
1089
|
+
): number {
|
|
1090
|
+
outro()
|
|
1091
|
+
frameError(message)
|
|
1092
|
+
emitPluginUpdate(opts, {
|
|
1093
|
+
root: PROJECT_ROOT,
|
|
1094
|
+
...(id === undefined ? {} : { id }),
|
|
1095
|
+
...(before === undefined ? {} : { before }),
|
|
1096
|
+
state: 'refused',
|
|
1097
|
+
reason,
|
|
1098
|
+
message,
|
|
1099
|
+
})
|
|
1100
|
+
return 1
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function emitPluginUpdate(
|
|
1104
|
+
opts: PluginUpdateOptions,
|
|
1105
|
+
record: PluginUpdateRecord,
|
|
1106
|
+
): void {
|
|
1107
|
+
if (opts.json !== true) return
|
|
1108
|
+
process.stdout.write(
|
|
1109
|
+
`${JSON.stringify({ ...record, message: singleLine(record.message) })}\n`,
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
833
1113
|
/** What a reader does about the one way the corpus fails to build. */
|
|
834
1114
|
const REACH_REFUSALS: Record<ReachRefusal, string> = {
|
|
835
1115
|
'no-skills':
|
package/src/commands/feedback.ts
CHANGED
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
missingField,
|
|
8
8
|
missingFieldMessage,
|
|
9
9
|
} from '@/commands/feedback-format'
|
|
10
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
10
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
11
11
|
import { creationRel } from '@/record-root'
|
|
12
12
|
import { createGithubIssue } from '@/github'
|
|
13
13
|
import { issueFailureMessage } from '@/github-format'
|
|
14
|
-
import { frameError, frameSuccess, palette } from '@/ui'
|
|
14
|
+
import { frameError, frameSuccess, logWarn, palette } from '@/ui'
|
|
15
15
|
|
|
16
16
|
function readStdin(): Promise<string> {
|
|
17
17
|
return new Promise((resolveStream, rejectStream) => {
|
|
@@ -45,7 +45,7 @@ function isToolkitSource(): boolean {
|
|
|
45
45
|
* hand, so each writes under its own name and the enclosing folder keeps the
|
|
46
46
|
* single ignore entry and the single backed-folder entry it already had.
|
|
47
47
|
*/
|
|
48
|
-
function writeLocal(body: string): string {
|
|
48
|
+
function writeLocal(body: string, mismatch: string | undefined): string {
|
|
49
49
|
// Resolved against the same root the write joins onto, so a checkout that has
|
|
50
50
|
// not migrated its records writes this beside the ones already there rather
|
|
51
51
|
// than opening a second root nothing reads.
|
|
@@ -55,7 +55,16 @@ function writeLocal(body: string): string {
|
|
|
55
55
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
|
56
56
|
const filePath = join(reviewDir, filename)
|
|
57
57
|
writeFileSync(filePath, `${body}\n`, 'utf8')
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
// Manual frame rather than `frameSuccess`, since the mismatch warning is a
|
|
60
|
+
// frame-interior line landing between the opener and the command title,
|
|
61
|
+
// matching the convention `#1587` set at its other six sites.
|
|
62
|
+
const { GREEN, GREY, NC, WHITE } = palette(process.stderr)
|
|
63
|
+
process.stderr.write(`${GREY}┌${NC}\n`)
|
|
64
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`${GREY}│${NC} ${WHITE}canon feedback${NC}\n${GREY}│${NC}\n${GREY}│${NC} ${GREEN}✓${NC} ${join(relativeDir, filename)}\n${GREY}└${NC}\n`,
|
|
67
|
+
)
|
|
59
68
|
return filePath
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -124,7 +133,8 @@ export function register(program: Command): void {
|
|
|
124
133
|
return
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
const
|
|
136
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
137
|
+
const filePath = writeLocal(body, mismatch)
|
|
128
138
|
process.stdout.write(`${filePath}\n`)
|
|
129
139
|
})
|
|
130
140
|
}
|
package/src/commands/gate.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
summarize,
|
|
13
13
|
} from '@/gate/sequencer'
|
|
14
14
|
import { STAGES } from '@/gate/stages'
|
|
15
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
15
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
16
16
|
import {
|
|
17
17
|
intro,
|
|
18
18
|
logError,
|
|
@@ -89,6 +89,7 @@ export function register(program: Command): void {
|
|
|
89
89
|
|
|
90
90
|
async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
91
91
|
const root = PROJECT_ROOT
|
|
92
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
92
93
|
const emitJson = opts.json ?? false
|
|
93
94
|
const nested = opts.nested ?? false
|
|
94
95
|
const write = opts.write ?? true
|
|
@@ -102,6 +103,7 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
|
102
103
|
? { scoped: false, files: [] }
|
|
103
104
|
: await collectChangedFiles(run)
|
|
104
105
|
if (!emitJson && changed.notice !== undefined) logWarn(changed.notice)
|
|
106
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
105
107
|
|
|
106
108
|
const ctx: GateContext = {
|
|
107
109
|
root,
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -78,6 +78,7 @@ interface CheckOptions {
|
|
|
78
78
|
readonly writes?: string
|
|
79
79
|
readonly escapes?: string
|
|
80
80
|
readonly escapesWatched?: boolean
|
|
81
|
+
readonly concurrentSessions?: string
|
|
81
82
|
readonly json?: boolean
|
|
82
83
|
readonly strict?: boolean
|
|
83
84
|
}
|
|
@@ -349,7 +350,7 @@ function runCheck(
|
|
|
349
350
|
|
|
350
351
|
const parsed = parseTarget(target)
|
|
351
352
|
if (parsed === undefined) {
|
|
352
|
-
logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
|
|
353
|
+
logError('Invalid target. Use <category>:<command>, e.g. claude:docs-fold.')
|
|
353
354
|
outro()
|
|
354
355
|
process.exitCode = 1
|
|
355
356
|
return
|
|
@@ -376,6 +377,7 @@ function runCheck(
|
|
|
376
377
|
options.escapes === undefined
|
|
377
378
|
? undefined
|
|
378
379
|
: options.escapesWatched === true,
|
|
380
|
+
concurrentSessions: readPathList(options.concurrentSessions),
|
|
379
381
|
envelope: readEnvelope(options.envelope),
|
|
380
382
|
},
|
|
381
383
|
)
|
|
@@ -414,7 +416,10 @@ export function register(program: Command): void {
|
|
|
414
416
|
sandbox
|
|
415
417
|
.command('check')
|
|
416
418
|
.description('Check a provisioned sandbox against a scenario expectation')
|
|
417
|
-
.argument(
|
|
419
|
+
.argument(
|
|
420
|
+
'<target>',
|
|
421
|
+
'Scenario as <category>:<command>, e.g. claude:docs-fold',
|
|
422
|
+
)
|
|
418
423
|
.argument('[arm]', 'Named scenario arm, e.g. drift')
|
|
419
424
|
.helpOption('-h, --help', 'Show this help message')
|
|
420
425
|
.option('--envelope <file>', 'Run envelope JSON from claude -p')
|
|
@@ -427,6 +432,10 @@ export function register(program: Command): void {
|
|
|
427
432
|
'--escapes-watched',
|
|
428
433
|
'At least one watched root held a target this run, so a zero-escape file is a clean watch rather than one with nothing to watch',
|
|
429
434
|
)
|
|
435
|
+
.option(
|
|
436
|
+
'--concurrent-sessions <file>',
|
|
437
|
+
'Newline-delimited sessions live in the registry both before and after this run, a witness for an unbounded escape',
|
|
438
|
+
)
|
|
430
439
|
.option('--json', 'Emit the verdict as JSON on stdout')
|
|
431
440
|
.option('--strict', 'Exit non-zero when the arm declares no expectation')
|
|
432
441
|
.addHelpText(
|
|
@@ -434,8 +443,8 @@ export function register(program: Command): void {
|
|
|
434
443
|
[
|
|
435
444
|
'',
|
|
436
445
|
'Examples:',
|
|
437
|
-
' canon sandbox check claude:docs drift',
|
|
438
|
-
' canon sandbox check claude:docs drift --envelope run.json --json',
|
|
446
|
+
' canon sandbox check claude:docs-fold drift',
|
|
447
|
+
' canon sandbox check claude:docs-fold drift --envelope run.json --json',
|
|
439
448
|
'',
|
|
440
449
|
'Exit codes: 0 on pass or unchecked, 1 on failure.',
|
|
441
450
|
'With --strict, unchecked exits 1 as well.',
|
package/src/design/components.ts
CHANGED
|
@@ -998,6 +998,18 @@ h2 .count {
|
|
|
998
998
|
line-height: 1.55;
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
|
+
.gloss-group {
|
|
1002
|
+
margin: 1.1rem 0 0.4rem;
|
|
1003
|
+
font-family: var(--teach-sans);
|
|
1004
|
+
font-size: 0.8125rem;
|
|
1005
|
+
font-weight: 600;
|
|
1006
|
+
color: var(--color-muted);
|
|
1007
|
+
text-transform: uppercase;
|
|
1008
|
+
letter-spacing: 0.02em;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.gloss-group:first-of-type { margin-top: 0; }
|
|
1012
|
+
|
|
1001
1013
|
.gterm b { font-weight: 700; }
|
|
1002
1014
|
/* The markdown source separates a term from its definition with a colon,
|
|
1003
1015
|
and the house standard bans an em dash outright, so the view mirrors
|
package/src/gate/measures.ts
CHANGED
|
@@ -110,6 +110,17 @@ export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
|
|
|
110
110
|
*/
|
|
111
111
|
export const SANDBOX_UNDECLARED_CEILING = 47
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Skills the sandbox skill census reports `asserted`, taken from `canon
|
|
115
|
+
* sandbox coverage --skills` against a clean tree. A dropped pairing lowers
|
|
116
|
+
* this directly, unlike the ceiling above, which a rename can starve without
|
|
117
|
+
* moving: fourteen arms drifted off their skill's name in one branch and the
|
|
118
|
+
* scenario-level ceiling stayed green throughout, since it counts scenarios
|
|
119
|
+
* declaring an expectation rather than skills a scenario reaches. Lowering
|
|
120
|
+
* this floor is a deliberate edit that says which skill lost its arm and why.
|
|
121
|
+
*/
|
|
122
|
+
export const SANDBOX_ASSERTED_FLOOR = 26
|
|
123
|
+
|
|
113
124
|
/**
|
|
114
125
|
* The retained counts the audit stage compares each run against. Spelled here
|
|
115
126
|
* rather than derived, because this stage only ever names the file in a remedy
|
|
@@ -740,10 +751,45 @@ export const sandboxCoverage: Measure = async (ctx) => {
|
|
|
740
751
|
}
|
|
741
752
|
}
|
|
742
753
|
|
|
754
|
+
const scenarioEmission = info(
|
|
755
|
+
`${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
const skillsRun = await ctx.cli(['sandbox', 'coverage', '--skills', '--json'])
|
|
759
|
+
|
|
760
|
+
if (skillsRun.exitCode !== 0) {
|
|
761
|
+
return {
|
|
762
|
+
emissions: [scenarioEmission],
|
|
763
|
+
unmeasured: `The skill census did not report (exit ${skillsRun.exitCode}). It ships in the checkout beside the scenario report, so a run that does not report is a broken command rather than an absent census.`,
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const skillsRecord = parseJson(skillsRun.stdout) as
|
|
768
|
+
| { asserted?: unknown; totalSkills?: unknown }
|
|
769
|
+
| undefined
|
|
770
|
+
const asserted = skillsRecord?.asserted
|
|
771
|
+
const totalSkills = skillsRecord?.totalSkills
|
|
772
|
+
|
|
773
|
+
if (typeof asserted !== 'number' || typeof totalSkills !== 'number') {
|
|
774
|
+
return {
|
|
775
|
+
emissions: [scenarioEmission],
|
|
776
|
+
failure:
|
|
777
|
+
'The skill census carried no asserted total, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --skills --json.',
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (asserted < SANDBOX_ASSERTED_FLOOR) {
|
|
782
|
+
return {
|
|
783
|
+
emissions: [scenarioEmission],
|
|
784
|
+
failure: `${asserted} of ${totalSkills} skills asserted, under the floor of ${SANDBOX_ASSERTED_FLOOR}. A rename or a moved arm likely dropped a skill's pairing; repair the arm's filename against its skill and say which skill lost its arm, or lower SANDBOX_ASSERTED_FLOOR in src/gate/measures.ts.`,
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
743
788
|
return {
|
|
744
789
|
emissions: [
|
|
790
|
+
scenarioEmission,
|
|
745
791
|
info(
|
|
746
|
-
`${
|
|
792
|
+
`${asserted} of ${totalSkills} skills asserted, against a floor of ${SANDBOX_ASSERTED_FLOOR}`,
|
|
747
793
|
),
|
|
748
794
|
],
|
|
749
795
|
}
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -78,6 +78,14 @@ export interface CheckInput {
|
|
|
78
78
|
* nothing to watch, both of which produce the same empty `escapes` list.
|
|
79
79
|
*/
|
|
80
80
|
readonly escapesWatched?: boolean
|
|
81
|
+
/**
|
|
82
|
+
* Names of sessions the client's own registry carried both before and after
|
|
83
|
+
* the run, so present rather than dispatched by it. Undefined when the
|
|
84
|
+
* caller supplied none, which is not the same as a run that had no witness:
|
|
85
|
+
* the registry carries no contract, so a client that stops writing a record
|
|
86
|
+
* per session makes this list empty regardless of who else was live.
|
|
87
|
+
*/
|
|
88
|
+
readonly concurrentSessions?: readonly string[]
|
|
81
89
|
readonly envelope?: RunEnvelope
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -386,11 +394,19 @@ function checkWriteScope(
|
|
|
386
394
|
* empty `escapes` list. `watched` is what tells them apart: a run that had
|
|
387
395
|
* nothing to watch reports unmeasured rather than passing on a diff it never
|
|
388
396
|
* had the target to take.
|
|
397
|
+
*
|
|
398
|
+
* A witnessed concurrent session never softens the verdict. `concurrentSessions`
|
|
399
|
+
* is evidence a reader can act on without a second lookup, appended to the
|
|
400
|
+
* message on an unbounded escape, never a reason to pass or skip one: a session
|
|
401
|
+
* merely alive throughout the run proves someone else was busy, not that a
|
|
402
|
+
* given file is theirs, and a real dispatch escape reads identically to an
|
|
403
|
+
* innocent sibling's write either way.
|
|
389
404
|
*/
|
|
390
405
|
function checkEscapeScope(
|
|
391
406
|
expectation: Expectation,
|
|
392
407
|
escapes: readonly string[] | undefined,
|
|
393
408
|
watched: boolean | undefined,
|
|
409
|
+
concurrentSessions: readonly string[] | undefined,
|
|
394
410
|
): KindOutcome {
|
|
395
411
|
if (expectation.escapeScope === undefined) return { results: [], skipped: [] }
|
|
396
412
|
|
|
@@ -416,12 +432,20 @@ function checkEscapeScope(
|
|
|
416
432
|
}
|
|
417
433
|
|
|
418
434
|
const globs = expectation.escapeScope.map((glob) => new Bun.Glob(glob))
|
|
435
|
+
const witnessCount = concurrentSessions?.length ?? 0
|
|
436
|
+
const witnessSuffix =
|
|
437
|
+
witnessCount > 0
|
|
438
|
+
? ` (${witnessCount} session${witnessCount === 1 ? '' : 's'} live throughout the run)`
|
|
439
|
+
: ''
|
|
419
440
|
|
|
420
441
|
return {
|
|
421
442
|
results: escapes.map((path) =>
|
|
422
443
|
globs.some((glob) => glob.match(path))
|
|
423
444
|
? { ok: true, message: `declared escape: ${path}` }
|
|
424
|
-
: {
|
|
445
|
+
: {
|
|
446
|
+
ok: false,
|
|
447
|
+
message: `unbounded escape: ${path}${witnessSuffix}`,
|
|
448
|
+
},
|
|
425
449
|
),
|
|
426
450
|
skipped: [],
|
|
427
451
|
}
|
|
@@ -514,6 +538,7 @@ export function checkExpectation(
|
|
|
514
538
|
expectation,
|
|
515
539
|
input.escapes,
|
|
516
540
|
input.escapesWatched,
|
|
541
|
+
input.concurrentSessions,
|
|
517
542
|
)
|
|
518
543
|
const reply = checkReply(expectation, input.envelope)
|
|
519
544
|
const envelope = checkEnvelope(expectation, input.envelope)
|
package/src/teach/nav.ts
CHANGED
|
@@ -109,6 +109,17 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
|
|
|
109
109
|
var input = document.getElementById("gfilter");
|
|
110
110
|
var list = document.getElementById("gloss");
|
|
111
111
|
if (!input || !list) return;
|
|
112
|
+
function updateGroups() {
|
|
113
|
+
list.querySelectorAll(".gloss-group").forEach(function (heading) {
|
|
114
|
+
var el = heading.nextElementSibling;
|
|
115
|
+
var any = false;
|
|
116
|
+
while (el && !el.classList.contains("gloss-group")) {
|
|
117
|
+
if (el.style.display !== "none") any = true;
|
|
118
|
+
el = el.nextElementSibling;
|
|
119
|
+
}
|
|
120
|
+
heading.style.display = any ? "" : "none";
|
|
121
|
+
});
|
|
122
|
+
}
|
|
112
123
|
input.addEventListener("input", function () {
|
|
113
124
|
var q = input.value.toLowerCase();
|
|
114
125
|
var n = 0;
|
|
@@ -118,6 +129,7 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
|
|
|
118
129
|
if (match) n++;
|
|
119
130
|
});
|
|
120
131
|
list.classList.toggle("none", n === 0);
|
|
132
|
+
updateGroups();
|
|
121
133
|
});
|
|
122
134
|
var clear = list.querySelector(".clear");
|
|
123
135
|
if (clear) {
|
|
@@ -457,8 +469,90 @@ function renderGlossaryEntry(entry: string): string {
|
|
|
457
469
|
return `<div class="gterm"><b>${escapeHtml(term)}</b><span>${escapeHtml(definition)}</span></div>`
|
|
458
470
|
}
|
|
459
471
|
|
|
460
|
-
|
|
461
|
-
|
|
472
|
+
const FIRST_SEEN_PATTERN = / First seen in (.+)\.$/
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The lesson or reference page an entry's own "First seen in" sentence
|
|
476
|
+
* names, absent when the entry predates that citation convention.
|
|
477
|
+
*/
|
|
478
|
+
function firstSeenFile(entry: string): string | undefined {
|
|
479
|
+
return FIRST_SEEN_PATTERN.exec(entry)?.[1]
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* `firstSeenFile` names a page free-form, per the `--first-seen` flag it
|
|
484
|
+
* comes from, so it may carry a directory prefix a lesson's own `file` does
|
|
485
|
+
* not. Comparing basenames is what keeps `lessons/0001-x.html` and
|
|
486
|
+
* `0001-x.html` resolving to the same lesson without a suffix match risking
|
|
487
|
+
* a false hit across two differently-prefixed filenames.
|
|
488
|
+
*/
|
|
489
|
+
function matchingLesson(
|
|
490
|
+
file: string,
|
|
491
|
+
metas: readonly LessonMeta[],
|
|
492
|
+
): LessonMeta | undefined {
|
|
493
|
+
const basename = file.split('/').pop()
|
|
494
|
+
return metas.find((meta) => meta.file === basename)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
interface GlossaryGroup {
|
|
498
|
+
readonly heading: string
|
|
499
|
+
readonly entries: readonly string[]
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const OTHER_TERMS_HEADING = 'Other terms'
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Groups already-alphabetical glossary entries by the lesson their own
|
|
506
|
+
* "First seen in" sentence names, in lesson order. An entry naming a
|
|
507
|
+
* reference page instead, or carrying no citation at all, cannot be
|
|
508
|
+
* attributed to a lesson and trails in its own group, keeping the
|
|
509
|
+
* alphabetical order the source entries already carry.
|
|
510
|
+
*/
|
|
511
|
+
function groupGlossaryEntries(
|
|
512
|
+
entries: readonly string[],
|
|
513
|
+
metas: readonly LessonMeta[],
|
|
514
|
+
): readonly GlossaryGroup[] {
|
|
515
|
+
const byLesson = new Map<string, string[]>()
|
|
516
|
+
const other: string[] = []
|
|
517
|
+
|
|
518
|
+
for (const entry of entries) {
|
|
519
|
+
const file = firstSeenFile(entry)
|
|
520
|
+
const lesson = file ? matchingLesson(file, metas) : undefined
|
|
521
|
+
|
|
522
|
+
if (lesson) {
|
|
523
|
+
const list = byLesson.get(lesson.file) ?? []
|
|
524
|
+
list.push(entry)
|
|
525
|
+
byLesson.set(lesson.file, list)
|
|
526
|
+
} else {
|
|
527
|
+
other.push(entry)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const groups: GlossaryGroup[] = []
|
|
532
|
+
for (const meta of metas) {
|
|
533
|
+
const list = byLesson.get(meta.file)
|
|
534
|
+
if (list) groups.push({ heading: meta.title, entries: list })
|
|
535
|
+
}
|
|
536
|
+
if (other.length > 0) {
|
|
537
|
+
groups.push({ heading: OTHER_TERMS_HEADING, entries: other })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return groups
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function renderGlossaryGroup(group: GlossaryGroup): string {
|
|
544
|
+
const entries = group.entries.map(renderGlossaryEntry).join('')
|
|
545
|
+
|
|
546
|
+
return `<h3 class="gloss-group">${escapeHtml(group.heading)}</h3>${entries}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function renderGlossarySection(
|
|
550
|
+
entries: readonly string[],
|
|
551
|
+
metas: readonly LessonMeta[],
|
|
552
|
+
): string {
|
|
553
|
+
const rendered = groupGlossaryEntries(entries, metas)
|
|
554
|
+
.map(renderGlossaryGroup)
|
|
555
|
+
.join('')
|
|
462
556
|
|
|
463
557
|
return `<h2>Glossary <span class="count">${entries.length}</span></h2>
|
|
464
558
|
<input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="term">
|
|
@@ -610,7 +704,7 @@ async function renderContentsPage(
|
|
|
610
704
|
referenceRows
|
|
611
705
|
? `<h2>Reference pages</h2>\n<ul class="toc">${referenceRows}</ul>`
|
|
612
706
|
: '',
|
|
613
|
-
renderGlossarySection(detail.glossary),
|
|
707
|
+
renderGlossarySection(detail.glossary, metas),
|
|
614
708
|
]
|
|
615
709
|
.filter((section) => section !== '')
|
|
616
710
|
.join('\n\n')
|
package/standards/glossary.md
CHANGED
|
@@ -58,6 +58,14 @@ A glossary failing these is non-conforming even when it satisfies every shape ru
|
|
|
58
58
|
- Name each category so a reader picks it from the term alone. A category a reader cannot predict makes the grouping a second thing to search.
|
|
59
59
|
- State a departure from any rule above in the file itself, naming what it departs from and why. A glossary serving no single body of material is the case that produces one, since a term drawn from everywhere has no first appearance to name.
|
|
60
60
|
|
|
61
|
+
## Rendered grouping
|
|
62
|
+
|
|
63
|
+
Applies to a rendered glossary page, never to the source file above, which stays the flat alphabetical list the `## Grouping` rules above govern.
|
|
64
|
+
|
|
65
|
+
- Group a rendered glossary by the lesson its own "First seen in" citation names, ordered by lesson order, under a sub-heading naming the lesson's title rather than its filename.
|
|
66
|
+
- Trail with an "Other terms" group holding any entry the citation cannot attribute to a lesson, whether it names a reference page instead or carries no citation at all. Keep it in the alphabetical order the source file already carries.
|
|
67
|
+
- Keep the workspace-wide term filter matching against every group, and drop a group's own heading once filtering leaves nothing under it.
|
|
68
|
+
|
|
61
69
|
## Template
|
|
62
70
|
|
|
63
71
|
```markdown
|