@erclx/canon 4.114.0 → 4.116.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/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/teach-workspace/SKILL.md +10 -6
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +4 -2
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/teach.md +26 -4
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +22 -4
- package/src/context/audit.ts +54 -18
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +278 -16
- package/src/teach/render.ts +94 -6
- package/standards/teach.md +13 -12
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
package/src/audits/catalog.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit'
|
|
|
2
2
|
import type { RankRefusal } from '@/claude/skills-rank'
|
|
3
3
|
import type { ReachRefusal } from '@/claude/skills-reach'
|
|
4
4
|
import type { ContextAuditRefusal } from '@/context/audit'
|
|
5
|
+
import type { SweepVerdict } from '@/context/classify/patterns'
|
|
5
6
|
import type { AuditRefusal } from '@/deps/audit'
|
|
6
7
|
import type { RestatedRefusal } from '@/gov/restated'
|
|
7
8
|
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
@@ -199,6 +200,45 @@ function architectureCounts(
|
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The retained key for each verdict the sweep can return. A record rather than
|
|
205
|
+
* a list, so the compiler fails here when the classifier gains a verdict,
|
|
206
|
+
* instead of that verdict's sections dropping out of the tally.
|
|
207
|
+
*/
|
|
208
|
+
const PLACEMENT_KEYS: Record<SweepVerdict, string> = {
|
|
209
|
+
KEEP: 'keep',
|
|
210
|
+
REWRITE: 'rewrite',
|
|
211
|
+
MOVE: 'move',
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPlacementVerdict(value: unknown): value is SweepVerdict {
|
|
215
|
+
return typeof value === 'string' && Object.hasOwn(PLACEMENT_KEYS, value)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Tallies the sweep's findings by verdict, with a zero for a verdict nothing
|
|
220
|
+
* returned, so a baseline reads the same three keys on every run.
|
|
221
|
+
*
|
|
222
|
+
* `KEEP` is retained beside the two that ask for a change. It moves when a
|
|
223
|
+
* section is added or split, which is what tells a falling `rewrite` count
|
|
224
|
+
* from a corpus that shrank.
|
|
225
|
+
*/
|
|
226
|
+
function placementCounts(record: unknown): Record<string, number> | undefined {
|
|
227
|
+
const findings = asObject(record)?.findings
|
|
228
|
+
if (!Array.isArray(findings)) return undefined
|
|
229
|
+
|
|
230
|
+
const counts: Record<string, number> = Object.fromEntries(
|
|
231
|
+
Object.values(PLACEMENT_KEYS).map((key) => [key, 0]),
|
|
232
|
+
)
|
|
233
|
+
for (const finding of findings) {
|
|
234
|
+
const verdict = asObject(finding)?.verdict
|
|
235
|
+
if (!isPlacementVerdict(verdict)) return undefined
|
|
236
|
+
counts[PLACEMENT_KEYS[verdict]] += 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return counts
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
function contextCounts(record: unknown): Record<string, number> | undefined {
|
|
203
243
|
const root = asObject(record)
|
|
204
244
|
if (root === undefined) return undefined
|
|
@@ -502,6 +542,18 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
502
542
|
absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
|
|
503
543
|
counts: contextCounts,
|
|
504
544
|
},
|
|
545
|
+
{
|
|
546
|
+
id: 'placement',
|
|
547
|
+
label: 'Context placement',
|
|
548
|
+
// Pinned to the regex layer rather than the resolved classifier setting.
|
|
549
|
+
// A count that moves with whichever model a machine runs is not
|
|
550
|
+
// comparable across clones, and the model layer turns a sub-second read
|
|
551
|
+
// into a multi-minute one. `document-health` is where the model reads.
|
|
552
|
+
argv: ['context', 'classify', 'sweep', '--backend', 'off', '--json'],
|
|
553
|
+
gatingExits: [],
|
|
554
|
+
corpus: 'tracked',
|
|
555
|
+
counts: placementCounts,
|
|
556
|
+
},
|
|
505
557
|
{
|
|
506
558
|
id: 'markdown',
|
|
507
559
|
label: 'Markdown corpus',
|
package/src/commands/context.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
missingSections,
|
|
15
15
|
PROVENANCE_FOLDER,
|
|
16
16
|
REQUIRED_SECTIONS,
|
|
17
|
+
REQUIRED_SECTIONS_BY_FOLDER,
|
|
18
|
+
requiredSections,
|
|
17
19
|
type SectionFinding,
|
|
18
20
|
} from '@/context/audit'
|
|
19
21
|
import {
|
|
@@ -129,8 +131,9 @@ export function register(program: Command): void {
|
|
|
129
131
|
'it holds more decisions than the cap. A record stating neither is',
|
|
130
132
|
'reported and never gated. --gate widens the gate to the other two',
|
|
131
133
|
'findings that are facts rather than judgments: a missing required',
|
|
132
|
-
'section and index',
|
|
133
|
-
'
|
|
134
|
+
'section and index drift. A context entry requires Overview and',
|
|
135
|
+
'Layout, and a wireframe requires Regions, States, Copy, and Not on',
|
|
136
|
+
'this surface. Entry length, reference form, table, provenance, narration,',
|
|
134
137
|
'and the record claim classification are judgments under both.',
|
|
135
138
|
'',
|
|
136
139
|
'Depth and bullet weight are stated over every markdown file rather',
|
|
@@ -740,6 +743,10 @@ async function runAudit(
|
|
|
740
743
|
renderWidth: RENDER_WIDTH,
|
|
741
744
|
provenanceFolder: PROVENANCE_FOLDER,
|
|
742
745
|
requiredSections: REQUIRED_SECTIONS,
|
|
746
|
+
// Keyed on the audited folder name. The flat key above stays for a
|
|
747
|
+
// caller that read it before wireframes owed a section, since turning
|
|
748
|
+
// it into this map would break that caller without an error.
|
|
749
|
+
requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
|
|
743
750
|
// Three states rather than two, so a record showing no finding says
|
|
744
751
|
// which terms were looked for. The key is absent when the run never
|
|
745
752
|
// scanned, which is `--citations-only`, and null when it scanned and
|
|
@@ -946,7 +953,8 @@ function reportReferenceForm(
|
|
|
946
953
|
|
|
947
954
|
/**
|
|
948
955
|
* Names the path each finding belongs to, which is an entry in the folder named
|
|
949
|
-
* under `.claude
|
|
956
|
+
* under `.claude/`, the folder itself in a domain split across one, and the
|
|
957
|
+
* file in any wireframe folder. States
|
|
950
958
|
* the reach on every run for the reason the provenance report does.
|
|
951
959
|
*
|
|
952
960
|
* This prints ahead of the four readability measures because a missing section
|
|
@@ -959,19 +967,31 @@ function reportSections(
|
|
|
959
967
|
): void {
|
|
960
968
|
logStep('Sections')
|
|
961
969
|
|
|
962
|
-
const
|
|
963
|
-
|
|
970
|
+
const names = [
|
|
971
|
+
...new Set(
|
|
972
|
+
folders
|
|
973
|
+
.filter((folder) => requiredSections(folder).length > 0)
|
|
974
|
+
.map((folder) => folder.name),
|
|
975
|
+
),
|
|
976
|
+
]
|
|
977
|
+
if (names.length === 0) {
|
|
964
978
|
logInfo(
|
|
965
|
-
`Out of scope. The
|
|
979
|
+
`Out of scope. The lists are stated in the standards governing ${Object.keys(
|
|
980
|
+
REQUIRED_SECTIONS_BY_FOLDER,
|
|
981
|
+
)
|
|
982
|
+
.map((name) => `canon/${name}/`)
|
|
983
|
+
.join(' and ')}, and no audited folder is one of them.`,
|
|
966
984
|
)
|
|
967
985
|
return
|
|
968
986
|
}
|
|
969
987
|
|
|
988
|
+
for (const name of names) {
|
|
989
|
+
logInfo(
|
|
990
|
+
`canon/${name}/ requires ${REQUIRED_SECTIONS_BY_FOLDER[name].join(', ')}.`,
|
|
991
|
+
)
|
|
992
|
+
}
|
|
970
993
|
logInfo(
|
|
971
|
-
|
|
972
|
-
)
|
|
973
|
-
logInfo(
|
|
974
|
-
'A heading at any level counts. Each entry answers for itself, except in a domain split across a folder, where a sibling answers for the rest.',
|
|
994
|
+
'A heading at any level counts. Each entry answers for itself, except in a context domain split across a folder, where a sibling answers for the rest. A wireframe answers for itself in every folder.',
|
|
975
995
|
)
|
|
976
996
|
|
|
977
997
|
if (missing.length === 0) {
|
package/src/commands/sessions.ts
CHANGED
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
type SessionReport,
|
|
11
11
|
selfOf,
|
|
12
12
|
} from '@/sessions/resolve'
|
|
13
|
+
import {
|
|
14
|
+
type Behind,
|
|
15
|
+
exportSession,
|
|
16
|
+
importSession,
|
|
17
|
+
} from '@/sessions/transfer/bundle'
|
|
13
18
|
import {
|
|
14
19
|
intro,
|
|
15
20
|
logInfo,
|
|
@@ -19,6 +24,7 @@ import {
|
|
|
19
24
|
pipeOutput,
|
|
20
25
|
plural,
|
|
21
26
|
} from '@/ui'
|
|
27
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
22
28
|
|
|
23
29
|
interface ListCommandOptions {
|
|
24
30
|
readonly json?: boolean
|
|
@@ -144,6 +150,222 @@ export function register(program: Command): void {
|
|
|
144
150
|
.action(async (opts: ListCommandOptions) => {
|
|
145
151
|
process.exitCode = await runList(opts)
|
|
146
152
|
})
|
|
153
|
+
|
|
154
|
+
sessions
|
|
155
|
+
.command('export')
|
|
156
|
+
.description(
|
|
157
|
+
'Pack a session transcript, its side folder, and a manifest into one local bundle',
|
|
158
|
+
)
|
|
159
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
160
|
+
.argument('<id>', 'The sessionId to export')
|
|
161
|
+
.option(
|
|
162
|
+
'--out <path>',
|
|
163
|
+
'Write the bundle here rather than under .canon/tmp/session-export/',
|
|
164
|
+
)
|
|
165
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
166
|
+
.addHelpText(
|
|
167
|
+
'after',
|
|
168
|
+
[
|
|
169
|
+
'',
|
|
170
|
+
'Exit codes:',
|
|
171
|
+
' 0 the bundle was written',
|
|
172
|
+
' 1 refused, with the reason on stderr and in the record',
|
|
173
|
+
'',
|
|
174
|
+
'The bundle is a gzip tarball on local disk and is never uploaded. It',
|
|
175
|
+
'holds whatever tool output passed through the session, so treat it as',
|
|
176
|
+
'private. Export runs no skill: write and push a handoff first where one',
|
|
177
|
+
'is wanted, and the manifest reports whether one was found and pushed.',
|
|
178
|
+
'',
|
|
179
|
+
'Refusal reasons: "not-found" (no transcript carries the id) and',
|
|
180
|
+
'"no-archive" (this Bun has no Bun.Archive).',
|
|
181
|
+
'',
|
|
182
|
+
'Examples:',
|
|
183
|
+
' canon sessions export <id>',
|
|
184
|
+
' canon sessions export <id> --out ~/session.tar.gz --json',
|
|
185
|
+
'',
|
|
186
|
+
].join('\n'),
|
|
187
|
+
)
|
|
188
|
+
.action(async (id: string, opts: ExportCommandOptions) => {
|
|
189
|
+
process.exitCode = await runExport(id, opts)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
sessions
|
|
193
|
+
.command('import')
|
|
194
|
+
.description(
|
|
195
|
+
'Place an exported session under this machine so claude can resume it',
|
|
196
|
+
)
|
|
197
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
198
|
+
.argument('<bundle>', 'The bundle an export wrote')
|
|
199
|
+
.option(
|
|
200
|
+
'--root <path>',
|
|
201
|
+
'The repository to resume in. Defaults to the main worktree',
|
|
202
|
+
)
|
|
203
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
204
|
+
.addHelpText(
|
|
205
|
+
'after',
|
|
206
|
+
[
|
|
207
|
+
'',
|
|
208
|
+
'Exit codes:',
|
|
209
|
+
' 0 the session was placed, with any lag reported in "behind"',
|
|
210
|
+
' 1 refused, with the reason on stderr and in the record',
|
|
211
|
+
'',
|
|
212
|
+
'Refusal reasons: "not-a-bundle", "other-repository" (the origin',
|
|
213
|
+
'differs), "exists" (the transcript or side folder is already there),',
|
|
214
|
+
'"live-here" (the id is live on this machine), "write-failed" (a',
|
|
215
|
+
'write failed partway), and "no-archive". A failure partway removes',
|
|
216
|
+
'every file this run placed, and names any it could not remove.',
|
|
217
|
+
'',
|
|
218
|
+
'"behind" lists "repository-behind" when this checkout lacks the',
|
|
219
|
+
'exported HEAD, and "records-behind" when the records history lacks',
|
|
220
|
+
'the exported records commit, which canon records pull brings.',
|
|
221
|
+
'',
|
|
222
|
+
'Examples:',
|
|
223
|
+
' canon sessions import session.tar.gz',
|
|
224
|
+
' canon sessions import session.tar.gz --root ~/repos/canon --json',
|
|
225
|
+
'',
|
|
226
|
+
].join('\n'),
|
|
227
|
+
)
|
|
228
|
+
.action(async (bundle: string, opts: ImportCommandOptions) => {
|
|
229
|
+
process.exitCode = await runImport(bundle, opts)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface ExportCommandOptions {
|
|
234
|
+
readonly out?: string
|
|
235
|
+
readonly json?: boolean
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface ImportCommandOptions {
|
|
239
|
+
readonly root?: string
|
|
240
|
+
readonly json?: boolean
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether a session id is live in the local roster. An unreadable roster
|
|
245
|
+
* answers false, since the roster is advisory here and the manifest says so.
|
|
246
|
+
*/
|
|
247
|
+
async function isLiveHere(id: string): Promise<boolean> {
|
|
248
|
+
const report = await resolveSessions()
|
|
249
|
+
return (
|
|
250
|
+
report.kind === 'resolved' &&
|
|
251
|
+
report.sessions.some((session) => session.sessionId === id)
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writeRecord(json: boolean | undefined, record: object): void {
|
|
256
|
+
if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function runExport(
|
|
260
|
+
id: string,
|
|
261
|
+
opts: ExportCommandOptions,
|
|
262
|
+
): Promise<number> {
|
|
263
|
+
const outcome = await exportSession({
|
|
264
|
+
id,
|
|
265
|
+
out: opts.out ? resolve(opts.out) : undefined,
|
|
266
|
+
isLive: isLiveHere,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
intro('canon sessions export')
|
|
270
|
+
|
|
271
|
+
if (!outcome.ok) {
|
|
272
|
+
logStep('Refused')
|
|
273
|
+
logWarn(outcome.message)
|
|
274
|
+
outro()
|
|
275
|
+
writeRecord(opts.json, { ...outcome, sessionId: id })
|
|
276
|
+
return 1
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { manifest } = outcome
|
|
280
|
+
logStep('Bundle')
|
|
281
|
+
logInfo(`Wrote ${outcome.path}`)
|
|
282
|
+
logInfo(`${plural(outcome.files.length, 'side file')} beside the transcript`)
|
|
283
|
+
|
|
284
|
+
if (manifest.live) {
|
|
285
|
+
logWarn(
|
|
286
|
+
'The session is live, so the copy stops at its last whole line and resuming elsewhere forks it.',
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
logStep('Handoff')
|
|
291
|
+
if (manifest.handoff === null) {
|
|
292
|
+
logInfo(
|
|
293
|
+
'No compact note since the session began, so resume the transcript.',
|
|
294
|
+
)
|
|
295
|
+
} else if (manifest.handoffCommitted) {
|
|
296
|
+
logInfo(`${manifest.handoff} is in the records history. Prefer it.`)
|
|
297
|
+
} else {
|
|
298
|
+
logWarn(
|
|
299
|
+
`${manifest.handoff} is not in the records history yet. Run canon records push so the other machine can pull it.`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
outro()
|
|
303
|
+
|
|
304
|
+
writeRecord(opts.json, {
|
|
305
|
+
ok: true,
|
|
306
|
+
path: outcome.path,
|
|
307
|
+
sessionId: id,
|
|
308
|
+
files: outcome.files,
|
|
309
|
+
manifest,
|
|
310
|
+
})
|
|
311
|
+
return 0
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const BEHIND_REMEDIES: Record<Behind, string> = {
|
|
315
|
+
'repository-behind':
|
|
316
|
+
'This checkout lacks the exported HEAD. Fetch and check out the branch before resuming.',
|
|
317
|
+
'records-behind':
|
|
318
|
+
'The records history lacks the exported commit. Run canon records pull to bring the handoff.',
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function runImport(
|
|
322
|
+
bundle: string,
|
|
323
|
+
opts: ImportCommandOptions,
|
|
324
|
+
): Promise<number> {
|
|
325
|
+
const root = opts.root ? resolve(opts.root) : await mainWorktreeRoot()
|
|
326
|
+
const outcome = await importSession({
|
|
327
|
+
bundle: resolve(bundle),
|
|
328
|
+
root,
|
|
329
|
+
isLive: isLiveHere,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
intro('canon sessions import')
|
|
333
|
+
|
|
334
|
+
if (!outcome.ok) {
|
|
335
|
+
logStep('Refused')
|
|
336
|
+
logWarn(outcome.message)
|
|
337
|
+
outro()
|
|
338
|
+
writeRecord(opts.json, outcome)
|
|
339
|
+
return 1
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
logStep('Placed')
|
|
343
|
+
logInfo(outcome.transcript)
|
|
344
|
+
logInfo(
|
|
345
|
+
`Folder ${outcome.folder}, derived from ${root}. --continue looks there, and --resume finds the id in any folder.`,
|
|
346
|
+
)
|
|
347
|
+
for (const lag of outcome.behind) logWarn(BEHIND_REMEDIES[lag])
|
|
348
|
+
|
|
349
|
+
logStep('Resume')
|
|
350
|
+
logInfo(
|
|
351
|
+
outcome.manifest.recommend === 'handoff'
|
|
352
|
+
? `Recommended: the handoff at ${outcome.manifest.handoff}, since the transcript carries the source machine's absolute paths.`
|
|
353
|
+
: 'Recommended: the transcript, since no handoff was written since the session began.',
|
|
354
|
+
)
|
|
355
|
+
for (const route of outcome.routes) logInfo(route)
|
|
356
|
+
outro()
|
|
357
|
+
|
|
358
|
+
writeRecord(opts.json, {
|
|
359
|
+
ok: true,
|
|
360
|
+
sessionId: outcome.manifest.sessionId,
|
|
361
|
+
folder: outcome.folder,
|
|
362
|
+
transcript: outcome.transcript,
|
|
363
|
+
files: outcome.files,
|
|
364
|
+
behind: outcome.behind,
|
|
365
|
+
routes: outcome.routes,
|
|
366
|
+
manifest: outcome.manifest,
|
|
367
|
+
})
|
|
368
|
+
return 0
|
|
147
369
|
}
|
|
148
370
|
|
|
149
371
|
async function runList(opts: ListCommandOptions): Promise<number> {
|
package/src/commands/teach.ts
CHANGED
|
@@ -393,9 +393,16 @@ export function register(program: Command): void {
|
|
|
393
393
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
394
394
|
'',
|
|
395
395
|
'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
|
|
396
|
-
'list, or raw block, and renders it through the same components
|
|
397
|
-
'fixture lesson is generated from. Content the
|
|
398
|
-
'takes type raw, carrying its own html verbatim,
|
|
396
|
+
'list, refs, or raw block, and renders it through the same components',
|
|
397
|
+
'the fixture lesson is generated from. Content the structured blocks',
|
|
398
|
+
'cannot express takes type raw, carrying its own html verbatim,',
|
|
399
|
+
'unescaped.',
|
|
400
|
+
'',
|
|
401
|
+
'A paragraph takes an optional cites array of reference numbers,',
|
|
402
|
+
'rendered as footnote markers after its text. The one refs block',
|
|
403
|
+
'lists { title, url?, note? } items numbered from 1, and a cite that',
|
|
404
|
+
'resolves to no item, a cite on a lede, or a second refs block is',
|
|
405
|
+
'refused. A url must be http or https.',
|
|
399
406
|
'',
|
|
400
407
|
'Takes no topic and no --root: the verb is a stateless transform,',
|
|
401
408
|
'reading nothing off a workspace on disk.',
|
|
@@ -797,7 +804,9 @@ function reportNav(
|
|
|
797
804
|
root: outcome.root,
|
|
798
805
|
contents: outcome.contents,
|
|
799
806
|
lessons: outcome.lessons,
|
|
807
|
+
reference: outcome.reference,
|
|
800
808
|
skipped: outcome.skipped,
|
|
809
|
+
unresolved: outcome.unresolved,
|
|
801
810
|
})}\n`,
|
|
802
811
|
)
|
|
803
812
|
return 0
|
|
@@ -810,14 +819,23 @@ function reportNav(
|
|
|
810
819
|
for (const path of outcome.contents) logInfo(path)
|
|
811
820
|
logStep('Lessons rewritten')
|
|
812
821
|
logInfo(String(outcome.lessons))
|
|
822
|
+
logStep('Reference pages rendered')
|
|
823
|
+
logInfo(String(outcome.reference))
|
|
813
824
|
|
|
814
825
|
if (outcome.skipped.length > 0) {
|
|
815
|
-
logStep('Refused, missing a chrome marker')
|
|
826
|
+
logStep('Refused, missing a chrome marker or the markdown renderer')
|
|
816
827
|
for (const skip of outcome.skipped) {
|
|
817
828
|
logWarn(`${skip.file}: no ${skip.missing}`)
|
|
818
829
|
}
|
|
819
830
|
}
|
|
820
831
|
|
|
832
|
+
if (outcome.unresolved.length > 0) {
|
|
833
|
+
logStep('Lesson references naming no lesson')
|
|
834
|
+
for (const reference of outcome.unresolved) {
|
|
835
|
+
logWarn(`${reference.file}: lesson ${reference.lesson}`)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
821
839
|
outro()
|
|
822
840
|
return 0
|
|
823
841
|
}
|
package/src/context/audit.ts
CHANGED
|
@@ -45,19 +45,35 @@ export const CATALOG_ROW_CHECKPOINT = 6
|
|
|
45
45
|
const CATALOG_NAMED_RATIO = 0.6
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* Required sections per audited folder name, each in the order its standard
|
|
49
|
+
* states them.
|
|
49
50
|
*
|
|
50
|
-
* The
|
|
51
|
-
* checkpoints above quote their numbers. A parser over
|
|
51
|
+
* The lists are held here rather than read out of the standards, the way the
|
|
52
|
+
* four checkpoints above quote their numbers. A parser over a standard's prose
|
|
52
53
|
* would decide which sections are required from the wording around them, so it
|
|
53
54
|
* fails on a rewrite of that wording rather than on a defect in an entry.
|
|
54
55
|
*
|
|
55
56
|
* These names do not generalize the way a length threshold does, which is why
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* each list is keyed on the folder whose standard states it. `context.md`
|
|
58
|
+
* requires an overview and a layout. `wireframes.md` asks four questions of a
|
|
59
|
+
* surface and answers each in a section, so those four are required and
|
|
60
|
+
* `## Behavior` is not, since a static surface has no interaction to state. A
|
|
61
|
+
* diagram entry declares a heading per kind and its standard requires none.
|
|
59
62
|
*/
|
|
60
|
-
export const
|
|
63
|
+
export const REQUIRED_SECTIONS_BY_FOLDER: Readonly<
|
|
64
|
+
Record<string, readonly string[]>
|
|
65
|
+
> = {
|
|
66
|
+
context: ['Overview', 'Layout'],
|
|
67
|
+
wireframes: ['Regions', 'States', 'Copy', 'Not on this surface'],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The context standard's list, kept as its own export because the audit record
|
|
72
|
+
* has published it flat under `checkpoints.requiredSections` since before any
|
|
73
|
+
* other folder required a section.
|
|
74
|
+
*/
|
|
75
|
+
export const REQUIRED_SECTIONS: readonly string[] =
|
|
76
|
+
REQUIRED_SECTIONS_BY_FOLDER.context
|
|
61
77
|
|
|
62
78
|
const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
|
|
63
79
|
const TABLE_ROW = /^\s*\|/
|
|
@@ -226,7 +242,7 @@ export interface EntryReport {
|
|
|
226
242
|
readonly bareReferences: readonly BareReferenceFinding[]
|
|
227
243
|
/**
|
|
228
244
|
* Required sections this entry declares, in the standard's order, and empty
|
|
229
|
-
* outside
|
|
245
|
+
* outside a folder whose standard names them. What the folder is short of
|
|
230
246
|
* is `missingSections`, since one entry answers for its siblings.
|
|
231
247
|
*/
|
|
232
248
|
readonly sections: readonly string[]
|
|
@@ -550,17 +566,20 @@ function narration(
|
|
|
550
566
|
* Fenced blocks are skipped for the reason the scans above skip them. A
|
|
551
567
|
* standard quoted inside an example declares nothing about the entry quoting it.
|
|
552
568
|
*/
|
|
553
|
-
function declaredSections(
|
|
569
|
+
function declaredSections(
|
|
570
|
+
lines: readonly BodyLine[],
|
|
571
|
+
required: readonly string[],
|
|
572
|
+
): string[] {
|
|
554
573
|
const found = new Set<string>()
|
|
555
574
|
|
|
556
575
|
for (const line of lines) {
|
|
557
576
|
if (line.fenced) continue
|
|
558
577
|
|
|
559
578
|
const match = line.text.match(HEADING_TEXT)
|
|
560
|
-
if (match &&
|
|
579
|
+
if (match && required.includes(match[1])) found.add(match[1])
|
|
561
580
|
}
|
|
562
581
|
|
|
563
|
-
return
|
|
582
|
+
return required.filter((section) => found.has(section))
|
|
564
583
|
}
|
|
565
584
|
|
|
566
585
|
/**
|
|
@@ -611,7 +630,9 @@ function bareReferences(
|
|
|
611
630
|
* The caller passes jurisdiction rather than deriving it from `rel`, because a
|
|
612
631
|
* path prefix hardcodes what `--folder` exists to override and misses a domain
|
|
613
632
|
* split into `context/<sub-area>/`. Sibling names arrive the same way and for
|
|
614
|
-
* the same reason, since the folder an entry sits in is what holds them.
|
|
633
|
+
* the same reason, since the folder an entry sits in is what holds them. The
|
|
634
|
+
* required sections arrive apart from jurisdiction, since a wireframe owes
|
|
635
|
+
* sections under a standard that states no provenance rule.
|
|
615
636
|
*/
|
|
616
637
|
export function measureEntry(
|
|
617
638
|
rel: string,
|
|
@@ -619,6 +640,7 @@ export function measureEntry(
|
|
|
619
640
|
governsContent = true,
|
|
620
641
|
terms?: NarrationTerms,
|
|
621
642
|
siblings: readonly string[] = [],
|
|
643
|
+
required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
|
|
622
644
|
): EntryReport {
|
|
623
645
|
const lines = bodyLines(source)
|
|
624
646
|
|
|
@@ -632,7 +654,7 @@ export function measureEntry(
|
|
|
632
654
|
provenance: governsContent ? provenance(lines) : [],
|
|
633
655
|
narration: governsContent && terms ? narration(lines, terms) : [],
|
|
634
656
|
bareReferences: bareReferences(lines, siblings),
|
|
635
|
-
sections:
|
|
657
|
+
sections: declaredSections(lines, required),
|
|
636
658
|
stub: isStubSeed(source),
|
|
637
659
|
governed: governsContent,
|
|
638
660
|
}
|
|
@@ -713,6 +735,7 @@ export async function measureFolders(
|
|
|
713
735
|
governsContent(folder),
|
|
714
736
|
terms,
|
|
715
737
|
names.filter((name) => name !== self),
|
|
738
|
+
requiredSections(folder),
|
|
716
739
|
),
|
|
717
740
|
)
|
|
718
741
|
}
|
|
@@ -726,6 +749,13 @@ export function governsContent(folder: AuditedFolder): boolean {
|
|
|
726
749
|
return folder.name === PROVENANCE_FOLDER
|
|
727
750
|
}
|
|
728
751
|
|
|
752
|
+
/** Names the sections the folder's standard requires, empty where it states none. */
|
|
753
|
+
export function requiredSections(folder: AuditedFolder): readonly string[] {
|
|
754
|
+
return Object.hasOwn(REQUIRED_SECTIONS_BY_FOLDER, folder.name)
|
|
755
|
+
? REQUIRED_SECTIONS_BY_FOLDER[folder.name]
|
|
756
|
+
: []
|
|
757
|
+
}
|
|
758
|
+
|
|
729
759
|
/**
|
|
730
760
|
* Reports whether a bare sibling name here is a reference by construction.
|
|
731
761
|
*
|
|
@@ -765,6 +795,11 @@ export function matchesSiblings(folder: AuditedFolder): boolean {
|
|
|
765
795
|
* The standard sanctions omitting `## Layout` from a domain owning no paths,
|
|
766
796
|
* which no measure can tell from an entry that forgot it. An entry of that
|
|
767
797
|
* shape therefore reports, which is a reason this is printed and never gated on.
|
|
798
|
+
*
|
|
799
|
+
* A wireframe folder answers per file whether nested or not, since its
|
|
800
|
+
* standard keeps one surface per file. A subfolder of wireframes groups
|
|
801
|
+
* surfaces the way the named context folder groups domains, so a conforming
|
|
802
|
+
* sibling there says nothing about the surface beside it.
|
|
768
803
|
*/
|
|
769
804
|
export function missingSections(
|
|
770
805
|
root: string,
|
|
@@ -774,11 +809,12 @@ export function missingSections(
|
|
|
774
809
|
const byRel = new Map(entries.map((entry) => [entry.rel, entry]))
|
|
775
810
|
const findings: SectionFinding[] = []
|
|
776
811
|
|
|
777
|
-
const shortOf = (declared: readonly string[]): string[] =>
|
|
778
|
-
REQUIRED_SECTIONS.filter((name) => !declared.includes(name))
|
|
779
|
-
|
|
780
812
|
for (const folder of folders) {
|
|
781
|
-
|
|
813
|
+
const required = requiredSections(folder)
|
|
814
|
+
if (required.length === 0 || folder.entries.length === 0) continue
|
|
815
|
+
|
|
816
|
+
const shortOf = (declared: readonly string[]): string[] =>
|
|
817
|
+
required.filter((name) => !declared.includes(name))
|
|
782
818
|
|
|
783
819
|
// A stub owes no sections, so it is dropped before either branch rather
|
|
784
820
|
// than inside them. Leaving one in the split-folder aggregate would let a
|
|
@@ -790,7 +826,7 @@ export function missingSections(
|
|
|
790
826
|
|
|
791
827
|
if (reports.length === 0) continue
|
|
792
828
|
|
|
793
|
-
if (folder.nested) {
|
|
829
|
+
if (folder.nested && governsContent(folder)) {
|
|
794
830
|
const missing = shortOf(reports.flatMap((entry) => entry.sections))
|
|
795
831
|
if (missing.length > 0) findings.push({ rel: folder.rel, missing })
|
|
796
832
|
continue
|
package/src/records/backup.ts
CHANGED
|
@@ -318,7 +318,7 @@ function failed(action: string, result: GitResult): BackupRefused {
|
|
|
318
318
|
* as written passes a records origin that publishes the payload through the
|
|
319
319
|
* other protocol.
|
|
320
320
|
*/
|
|
321
|
-
function remoteIdentity(url: string): string {
|
|
321
|
+
export function remoteIdentity(url: string): string {
|
|
322
322
|
return url
|
|
323
323
|
.trim()
|
|
324
324
|
.toLowerCase()
|
package/src/sandbox/census.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
|
|
|
69
69
|
/**
|
|
70
70
|
* Maps a scenario to the skill it drives, trying two spellings in order.
|
|
71
71
|
*
|
|
72
|
-
* `<category>-<command>` is the rule `canon/context/sandbox/coverage.md` states, and it
|
|
72
|
+
* `<category>-<command>` is the rule `canon/context/sandbox/coverage/census.md` states, and it
|
|
73
73
|
* alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
|
|
74
74
|
* rest: `claude/target-setup.sh` drives the `target-setup` skill, not a `claude-target-setup` that
|
|
75
75
|
* does not exist. Stating one spelling and shipping two is what let the audit
|
package/src/sessions/registry.ts
CHANGED
|
@@ -48,19 +48,23 @@ export interface SessionRecord {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Resolves the
|
|
51
|
+
* Resolves the client's configuration directory, which holds both the session
|
|
52
|
+
* registry and the projects folder a transcript lives in.
|
|
52
53
|
*
|
|
53
54
|
* `CLAUDE_CONFIG_DIR` comes first because a client honouring it writes its
|
|
54
55
|
* records nowhere near the home directory, and a read that ignored it would
|
|
55
56
|
* report an empty roster on a machine running sessions.
|
|
56
57
|
*/
|
|
57
|
-
export function
|
|
58
|
+
export function claudeConfigDir(): string {
|
|
58
59
|
const configured = process.env.CLAUDE_CONFIG_DIR
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
return configured && configured.length > 0
|
|
61
|
+
? configured
|
|
62
|
+
: join(homedir(), '.claude')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolves the folder holding one file per session. */
|
|
66
|
+
export function registryDir(): string {
|
|
67
|
+
return join(claudeConfigDir(), 'sessions')
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/**
|