@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/review-pr/REQUIREMENT.md +2 -0
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/commands.md +3 -1
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/skills-audit.md +8 -1
- package/docs/agents/teach.md +20 -4
- package/docs/workflow/ai-workflow.md +9 -9
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/markdown.ts +63 -2
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +18 -3
- package/src/context/audit.ts +6 -8
- package/src/gate/measures.ts +60 -0
- package/src/gate/stages.ts +12 -2
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- 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 +75 -7
- package/src/teach/render.ts +94 -6
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -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.',
|
|
@@ -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
|
}
|
package/src/context/audit.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
218
|
-
* `
|
|
219
|
-
*
|
|
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) : [],
|
package/src/gate/measures.ts
CHANGED
|
@@ -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.
|
package/src/gate/stages.ts
CHANGED
|
@@ -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.
|
|
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
|
*
|
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
|
/**
|