@erclx/canon 4.115.0 → 4.117.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 (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +12 -12
  4. package/claude/skills/create-skill/REQUIREMENT.md +3 -0
  5. package/claude/skills/create-skill/SKILL.md +3 -1
  6. package/claude/skills/docs-fold/SKILL.md +1 -1
  7. package/claude/skills/memory-review/SKILL.md +4 -4
  8. package/claude/skills/review-pr/REQUIREMENT.md +2 -0
  9. package/claude/skills/review-pr/SKILL.md +2 -2
  10. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
  11. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
  13. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  14. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
  15. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  16. package/claude/skills/role-worker/SKILL.md +2 -2
  17. package/claude/skills/session-compact/REQUIREMENT.md +2 -1
  18. package/claude/skills/session-compact/SKILL.md +5 -1
  19. package/claude/skills/teach-workspace/SKILL.md +5 -3
  20. package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
  21. package/claude/skills/write-human/references/source-material.md +2 -2
  22. package/docs/agents/commands.md +3 -1
  23. package/docs/agents/context-audit-checks.md +1 -1
  24. package/docs/agents/index.md +1 -1
  25. package/docs/agents/install-and-sync.md +1 -1
  26. package/docs/agents/markdown-audit.md +7 -3
  27. package/docs/agents/sandbox.md +1 -1
  28. package/docs/agents/sessions.md +84 -1
  29. package/docs/agents/skills-audit.md +8 -1
  30. package/docs/agents/teach.md +20 -4
  31. package/docs/workflow/ai-workflow.md +9 -9
  32. package/governance/rules/ui/440-surface-capture.md +2 -0
  33. package/package.json +1 -1
  34. package/scripts/core/check-capability-seeding.sh +4 -2
  35. package/src/claude/cases/misc.ts +5 -0
  36. package/src/claude/skills-audit.ts +59 -2
  37. package/src/commands/claude.ts +30 -0
  38. package/src/commands/markdown.ts +63 -2
  39. package/src/commands/sessions.ts +222 -0
  40. package/src/commands/teach.ts +18 -3
  41. package/src/context/audit.ts +6 -8
  42. package/src/gate/measures.ts +60 -0
  43. package/src/gate/stages.ts +12 -2
  44. package/src/markdown/ceiling.ts +70 -0
  45. package/src/markdown/structure.ts +22 -0
  46. package/src/records/backup.ts +1 -1
  47. package/src/sandbox/census.ts +1 -1
  48. package/src/sessions/registry.ts +11 -7
  49. package/src/sessions/transfer/bundle.ts +546 -0
  50. package/src/sessions/transfer/transcript.ts +148 -0
  51. package/src/teach/components/paragraph.tsx +19 -2
  52. package/src/teach/components/refs.tsx +28 -0
  53. package/src/teach/cross-references.ts +78 -0
  54. package/src/teach/lesson.ts +1 -1
  55. package/src/teach/nav.ts +75 -7
  56. package/src/teach/render.ts +94 -6
  57. package/src/tooling/inject.ts +13 -5
  58. package/src/tooling/manifest.ts +9 -0
  59. package/src/tooling/scan.ts +23 -6
  60. package/src/tooling/subfolder.ts +3 -3
  61. package/standards/context.md +2 -2
  62. package/standards/markdown.md +2 -0
  63. package/standards/skill.md +1 -0
  64. package/tooling/base/reference.md +2 -2
  65. package/tooling/web/configs/e2e/screenshot.ts +43 -7
  66. package/tooling/web/reference.md +1 -1
  67. /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
  68. /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -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.',
@@ -799,6 +806,7 @@ function reportNav(
799
806
  lessons: outcome.lessons,
800
807
  reference: outcome.reference,
801
808
  skipped: outcome.skipped,
809
+ unresolved: outcome.unresolved,
802
810
  })}\n`,
803
811
  )
804
812
  return 0
@@ -821,6 +829,13 @@ function reportNav(
821
829
  }
822
830
  }
823
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
+
824
839
  outro()
825
840
  return 0
826
841
  }
@@ -4,7 +4,7 @@ import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
4
4
  import type { AuditedFolder } from '@/context/folders'
5
5
  import type { NarrationTerms } from '@/context/narration'
6
6
  import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
7
- import { renderedHeight } from '@/markdown/structure'
7
+ import { documentHeight } from '@/markdown/structure'
8
8
  import { isStubSeed } from '@/seed-marker'
9
9
 
10
10
  /**
@@ -214,9 +214,10 @@ export interface EntryReport {
214
214
  readonly rel: string
215
215
  /**
216
216
  * Rendered lines across the whole file, counting frontmatter and fenced
217
- * blocks. It shares `renderedHeight` with the depth checkpoint in
218
- * `src/markdown/structure.ts`, since the two sit in one section of the
219
- * standard and a reader compares them.
217
+ * blocks. It counts through `documentHeight` in `src/markdown/structure.ts`,
218
+ * which shares `renderedHeight` with the depth checkpoint and with the
219
+ * document ceiling, since all three sit beside each other in the standard
220
+ * and a reader compares them.
220
221
  *
221
222
  * Fences are counted here and excluded there. The depth measure skips one so
222
223
  * an example cannot break the run around it, and a file measure has no run to
@@ -646,10 +647,7 @@ export function measureEntry(
646
647
 
647
648
  return {
648
649
  rel,
649
- lines: source
650
- .replace(/\n$/, '')
651
- .split('\n')
652
- .reduce((sum, text) => sum + renderedHeight(text), 0),
650
+ lines: documentHeight(source),
653
651
  catalogTables: catalogTables(lines),
654
652
  provenance: governsContent ? provenance(lines) : [],
655
653
  narration: governsContent && terms ? narration(lines, terms) : [],
@@ -13,6 +13,9 @@ import {
13
13
  measureArchitecture,
14
14
  } from '@/context/architecture'
15
15
  import { listRepositoryFiles } from '@/git-files'
16
+ import { ceilingFindings } from '@/markdown/ceiling'
17
+ import { resolveMarkdown } from '@/markdown/files'
18
+ import { CHECKPOINTS } from '@/markdown/structure'
16
19
  import {
17
20
  isShippedCorpus,
18
21
  REFERENCE_MARKER,
@@ -21,6 +24,7 @@ import {
21
24
  type ShippedReference,
22
25
  } from '@/shipped/references'
23
26
  import { surfaceDir } from '@/surface-root'
27
+ import { plural } from '@/ui'
24
28
  import {
25
29
  README_PARAPHRASE_MARKER,
26
30
  readmeCitationsIn,
@@ -273,6 +277,62 @@ export const architectureRecord: Measure = async (ctx) => {
273
277
  }
274
278
  }
275
279
 
280
+ /**
281
+ * Every tracked markdown document against the whole-document ceiling.
282
+ *
283
+ * Report-only for now: a document past the ceiling warns and the stage still
284
+ * passes, since the corpus carried dozens past it when the stage landed. The
285
+ * flip to a failure is a separate change once the count reads zero, and it
286
+ * replaces the warn path rather than sitting beside it.
287
+ *
288
+ * Read in-process rather than through `canon markdown audit`, whose exit code
289
+ * stays bans and dead links only. The plan and groundwork skills run that audit
290
+ * on a gitignored record, and a gating length there would fail every long plan.
291
+ */
292
+ export const documentCeiling: Measure = async (ctx) => {
293
+ const scope = await resolveMarkdown(ctx.root, [])
294
+ if (scope.kind === 'unavailable') {
295
+ return {
296
+ emissions: [],
297
+ unmeasured: 'git could not list the tree, so no document was measured.',
298
+ }
299
+ }
300
+
301
+ const documents = scope.files.flatMap((rel) => {
302
+ try {
303
+ return [{ rel, source: readFileSync(join(ctx.root, rel), 'utf8') }]
304
+ } catch {
305
+ return []
306
+ }
307
+ })
308
+ const findings = ceilingFindings(documents)
309
+ const over = findings.filter((finding) => finding.exempt === null)
310
+ const exempt = findings.length - over.length
311
+ const ceiling = CHECKPOINTS.ceiling
312
+
313
+ if (over.length === 0) {
314
+ const exemptNote = exempt > 0 ? `, ${exempt} exempt` : ''
315
+ return {
316
+ emissions: [
317
+ info(
318
+ `No document past the ${ceiling}-line ceiling across ${plural(documents.length, 'markdown file')}${exemptNote}`,
319
+ ),
320
+ ],
321
+ }
322
+ }
323
+
324
+ return {
325
+ emissions: [
326
+ ...over.map((finding) =>
327
+ warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
328
+ ),
329
+ info(
330
+ `${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
331
+ ),
332
+ ],
333
+ }
334
+ }
335
+
276
336
  /**
277
337
  * A second run of the records move should rewrite nothing, and the count is
278
338
  * only knowable once the folders themselves have landed.
@@ -3,6 +3,7 @@ import {
3
3
  auditSet,
4
4
  captureStamps,
5
5
  clientCommandCitations,
6
+ documentCeiling,
6
7
  markdownBans,
7
8
  type Measure,
8
9
  pluginManifests,
@@ -333,9 +334,11 @@ export const STAGES: readonly Stage[] = [
333
334
  checks: [{ kind: 'measure', measure: unreferencedRules }],
334
335
  },
335
336
  {
336
- // Only the citation half of the audit gates. Length, depth, table, and
337
+ // Only the citation half of the audit gates. Its length, depth, table, and
337
338
  // index findings are judgment thresholds, and failing a push on one would
338
- // make the stage something to route around.
339
+ // make the stage something to route around. The one length reading that
340
+ // will gate is the whole-document ceiling, which the Document ceiling stage
341
+ // below owns rather than this audit.
339
342
  id: 'context-citations',
340
343
  label: 'Context citations',
341
344
  checks: [
@@ -355,6 +358,13 @@ export const STAGES: readonly Stage[] = [
355
358
  label: 'Architecture record',
356
359
  checks: [{ kind: 'measure', measure: architectureRecord }],
357
360
  },
361
+ {
362
+ // Unscoped, since any branch can grow any document. It warns and passes
363
+ // until the corpus sits under the ceiling, then flips to a failure.
364
+ id: 'document-ceiling',
365
+ label: 'Document ceiling',
366
+ checks: [{ kind: 'measure', measure: documentCeiling }],
367
+ },
358
368
  {
359
369
  // A rule citing a file that moved fails silently. The consumed-copy drift
360
370
  // stage passes an authored rule and its copy that are wrong together, and
@@ -0,0 +1,70 @@
1
+ import { basename } from 'node:path'
2
+ import { bodyLines } from '@/markdown/scan'
3
+ import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
4
+
5
+ /**
6
+ * The marker exempting one document from the ceiling, written as a whole line
7
+ * outside a fence and naming a reason.
8
+ *
9
+ * The whole line rather than anywhere on it, so a standard quoting the marker
10
+ * inside a code span to document it does not exempt itself. A marker with no
11
+ * reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
12
+ * gives: a bare token reads as a line that meant to say something and did not.
13
+ */
14
+ const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
15
+
16
+ /**
17
+ * A changelog is exempt by name because the release tool rewrites it and would
18
+ * not keep a marker in place. A committed list of exempt paths was the
19
+ * alternative, and it turns every rename into a second edit with nothing to
20
+ * check the list against the tree.
21
+ */
22
+ const CHANGELOG = 'CHANGELOG.md'
23
+ const CHANGELOG_REASON = 'CHANGELOG.md is written by the release tool'
24
+
25
+ export interface CeilingDocument {
26
+ readonly rel: string
27
+ readonly source: string
28
+ }
29
+
30
+ export interface CeilingFinding {
31
+ readonly rel: string
32
+ readonly renderedLines: number
33
+ /** The stated reason, or null where the document is not exempt. */
34
+ readonly exempt: string | null
35
+ }
36
+
37
+ /** The reason a document is exempt from the ceiling, or null where it is not. */
38
+ export function lengthExemption(rel: string, source: string): string | null {
39
+ if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
40
+
41
+ for (const line of bodyLines(source)) {
42
+ if (line.fenced) continue
43
+ const match = EXEMPT_MARKER.exec(line.text.trim())
44
+ if (match) return match[1]
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Every document past the ceiling, longest first, exempt ones included with
52
+ * their reason so a report can count them rather than hide them.
53
+ */
54
+ export function ceilingFindings(
55
+ documents: readonly CeilingDocument[],
56
+ ): CeilingFinding[] {
57
+ return documents
58
+ .map(({ rel, source }) => ({
59
+ rel,
60
+ renderedLines: documentHeight(source),
61
+ source,
62
+ }))
63
+ .filter(({ renderedLines }) => renderedLines > CHECKPOINTS.ceiling)
64
+ .map(({ rel, renderedLines, source }) => ({
65
+ rel,
66
+ renderedLines,
67
+ exempt: lengthExemption(rel, source),
68
+ }))
69
+ .sort((a, b) => b.renderedLines - a.renderedLines)
70
+ }
@@ -116,6 +116,7 @@ export const CHECKPOINTS = {
116
116
  cadence: 3,
117
117
  spread: 5,
118
118
  opener: 2,
119
+ ceiling: 300,
119
120
  } as const
120
121
 
121
122
  /**
@@ -167,6 +168,11 @@ export interface Checkpoints {
167
168
  readonly spread: number
168
169
  /** Times one opening word may open a sentence in a paragraph before it is a pattern. */
169
170
  readonly opener: number
171
+ /**
172
+ * Rendered lines a whole document may reach, read by the Document ceiling
173
+ * gate stage rather than by any exit code of the audit.
174
+ */
175
+ readonly ceiling: number
170
176
  }
171
177
 
172
178
  export interface BulletFinding {
@@ -247,6 +253,22 @@ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
247
253
  return Math.max(1, Math.ceil(visibleText(text).length / width))
248
254
  }
249
255
 
256
+ /**
257
+ * Height a whole source occupies once wrapped, frontmatter and fenced blocks
258
+ * included.
259
+ *
260
+ * Nothing is excluded because a session pays for every line it loads, which is
261
+ * what separates this from the depth measure. The context audit's length
262
+ * reading and the document ceiling both count through here, so the two report
263
+ * one unit.
264
+ */
265
+ export function documentHeight(source: string, width = RENDER_WIDTH): number {
266
+ return source
267
+ .replace(/\n$/, '')
268
+ .split('\n')
269
+ .reduce((sum, text) => sum + renderedHeight(text, width), 0)
270
+ }
271
+
250
272
  /**
251
273
  * Reports whether a run is the peer list the standard exempts.
252
274
  *
@@ -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
  /**