@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.
Files changed (48) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/document-health/references/axes.md +1 -1
  3. package/claude/skills/target-setup/SKILL.md +1 -1
  4. package/claude/skills/target-setup/references/detect.md +43 -9
  5. package/claude/skills/teach-workspace/SKILL.md +10 -6
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
  7. package/docs/agents/audits.md +4 -2
  8. package/docs/agents/commands.md +4 -2
  9. package/docs/agents/context-audit-checks.md +6 -6
  10. package/docs/agents/context-audit.md +1 -1
  11. package/docs/agents/context-classify.md +1 -1
  12. package/docs/agents/index.md +1 -1
  13. package/docs/agents/sandbox.md +1 -1
  14. package/docs/agents/sessions.md +84 -1
  15. package/docs/agents/teach.md +26 -4
  16. package/docs/target-projects.md +4 -2
  17. package/package.json +1 -1
  18. package/src/audits/catalog.ts +52 -0
  19. package/src/commands/context.ts +30 -10
  20. package/src/commands/sessions.ts +222 -0
  21. package/src/commands/teach.ts +22 -4
  22. package/src/context/audit.ts +54 -18
  23. package/src/records/backup.ts +1 -1
  24. package/src/sandbox/census.ts +1 -1
  25. package/src/sessions/registry.ts +11 -7
  26. package/src/sessions/transfer/bundle.ts +546 -0
  27. package/src/sessions/transfer/transcript.ts +148 -0
  28. package/src/teach/components/paragraph.tsx +19 -2
  29. package/src/teach/components/refs.tsx +28 -0
  30. package/src/teach/cross-references.ts +78 -0
  31. package/src/teach/lesson.ts +1 -1
  32. package/src/teach/nav.ts +278 -16
  33. package/src/teach/render.ts +94 -6
  34. package/standards/teach.md +13 -12
  35. package/tooling/go/configs/.golangci.yml +25 -0
  36. package/tooling/go/configs/scripts/verify.sh +95 -0
  37. package/tooling/go/manifest.toml +13 -0
  38. package/tooling/go/reference.md +60 -0
  39. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  40. package/tooling/go/seeds/smoke_test.go +9 -0
  41. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  42. package/tooling/php/configs/phpstan.neon +5 -0
  43. package/tooling/php/configs/phpunit.xml +18 -0
  44. package/tooling/php/configs/scripts/verify.sh +100 -0
  45. package/tooling/php/manifest.toml +15 -0
  46. package/tooling/php/reference.md +74 -0
  47. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  48. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
@@ -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',
@@ -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
- 'drift. Entry length, reference form, table, provenance, narration,',
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/` and the folder itself in a domain split across one. States
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 governed = folders.filter(governsContent)
963
- if (governed.length === 0) {
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 list is stated in the standard governing canon/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
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
- `Covers canon/${PROVENANCE_FOLDER}/ alone, whose standard requires ${REQUIRED_SECTIONS.join(' and ')}.`,
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) {
@@ -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> {
@@ -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 the',
397
- 'fixture lesson is generated from. Content the three cannot express',
398
- 'takes type raw, carrying its own html verbatim, unescaped.',
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
  }
@@ -45,19 +45,35 @@ export const CATALOG_ROW_CHECKPOINT = 6
45
45
  const CATALOG_NAMED_RATIO = 0.6
46
46
 
47
47
  /**
48
- * Sections `standards/context.md` marks required, in the order it states them.
48
+ * Required sections per audited folder name, each in the order its standard
49
+ * states them.
49
50
  *
50
- * The list is held here rather than read out of the standard, the way the four
51
- * checkpoints above quote their numbers. A parser over the standard's prose
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
- * the measure is scoped to the folder `governsContent` names. A diagram entry
57
- * declares a heading per kind and a wireframe entry per screen, and neither
58
- * sibling standard states a required section at all.
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 REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
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 the folder whose standard names them. What the folder is short of
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(lines: readonly BodyLine[]): string[] {
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 && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
579
+ if (match && required.includes(match[1])) found.add(match[1])
561
580
  }
562
581
 
563
- return REQUIRED_SECTIONS.filter((section) => found.has(section))
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: governsContent ? declaredSections(lines) : [],
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
- if (!governsContent(folder) || folder.entries.length === 0) continue
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
@@ -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()
@@ -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
@@ -48,19 +48,23 @@ export interface SessionRecord {
48
48
  }
49
49
 
50
50
  /**
51
- * Resolves the folder holding one file per session.
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 registryDir(): string {
58
+ export function claudeConfigDir(): string {
58
59
  const configured = process.env.CLAUDE_CONFIG_DIR
59
- const base =
60
- configured && configured.length > 0
61
- ? configured
62
- : join(homedir(), '.claude')
63
- return join(base, 'sessions')
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
  /**