@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
@@ -0,0 +1,546 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmdirSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path'
12
+ import { $ } from 'bun'
13
+ import { gitEnv } from '@/git-env'
14
+ import { recordDir, recordRoot, SCRATCH } from '@/record-root'
15
+ import { remoteIdentity } from '@/records/backup'
16
+ import {
17
+ encodeProjectPath,
18
+ isSessionId,
19
+ locateTranscript,
20
+ projectsDir,
21
+ readSideFolder,
22
+ readTranscript,
23
+ transcriptFacts,
24
+ } from '@/sessions/transfer/transcript'
25
+
26
+ /** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
27
+ export const BUNDLE_FORMAT = 1
28
+
29
+ /**
30
+ * What travels beside the transcript, so the target can tell whether its own
31
+ * checkout is ready to resume the session and which route to take.
32
+ */
33
+ export interface Manifest {
34
+ readonly format: number
35
+ readonly sessionId: string
36
+ readonly title: string | null
37
+ /** The working directory the transcript last recorded. */
38
+ readonly cwd: string | null
39
+ /** The main worktree root of the repository the export read. */
40
+ readonly root: string
41
+ /** The `origin` remote reduced to `host/path`, or null where none is set. */
42
+ readonly origin: string | null
43
+ readonly branch: string | null
44
+ readonly head: string | null
45
+ /** The records history `HEAD`, or null where no records history exists. */
46
+ readonly recordsHead: string | null
47
+ readonly compacted: boolean
48
+ /** Whether the id was live in the roster at export, so a resume would fork it. */
49
+ readonly live: boolean
50
+ /** The newest compact note written since the session began, relative to the root. */
51
+ readonly handoff: string | null
52
+ /** Whether that note is committed in the records history. */
53
+ readonly handoffCommitted: boolean
54
+ readonly recommend: 'handoff' | 'transcript'
55
+ }
56
+
57
+ export type TransferRefusal =
58
+ | 'no-archive'
59
+ | 'not-found'
60
+ | 'not-a-bundle'
61
+ | 'other-repository'
62
+ | 'exists'
63
+ | 'live-here'
64
+ | 'write-failed'
65
+
66
+ /** A lag the import reports and does not refuse on, since each has a remedy the reader runs. */
67
+ export type Behind = 'repository-behind' | 'records-behind'
68
+
69
+ export interface TransferRefused {
70
+ readonly ok: false
71
+ readonly reason: TransferRefusal
72
+ readonly message: string
73
+ }
74
+
75
+ function refuse(reason: TransferRefusal, message: string): TransferRefused {
76
+ return { ok: false, reason, message }
77
+ }
78
+
79
+ const MANIFEST_PATH = 'manifest.json'
80
+ const SESSION_FOLDER = 'session'
81
+
82
+ function isManifest(value: unknown): value is Manifest {
83
+ if (typeof value !== 'object' || value === null) return false
84
+ const manifest = value as Partial<Manifest>
85
+ return (
86
+ manifest.format === BUNDLE_FORMAT &&
87
+ typeof manifest.sessionId === 'string' &&
88
+ isSessionId(manifest.sessionId) &&
89
+ typeof manifest.root === 'string' &&
90
+ (manifest.origin === null || typeof manifest.origin === 'string') &&
91
+ (manifest.head === null || typeof manifest.head === 'string') &&
92
+ (manifest.recordsHead === null ||
93
+ typeof manifest.recordsHead === 'string') &&
94
+ (manifest.recommend === 'handoff' || manifest.recommend === 'transcript')
95
+ )
96
+ }
97
+
98
+ /**
99
+ * `Bun.Archive` landed in a Bun the package declares no floor for, so an older
100
+ * runtime meets a missing constructor rather than a missing dependency.
101
+ */
102
+ function archiveUnavailable(): TransferRefused | undefined {
103
+ if (typeof Bun.Archive === 'function') return undefined
104
+ return refuse(
105
+ 'no-archive',
106
+ `This Bun (${Bun.version}) has no Bun.Archive, so no bundle can be packed or read. Upgrade Bun and re-run.`,
107
+ )
108
+ }
109
+
110
+ async function gitRead(cwd: string, args: string[]): Promise<string | null> {
111
+ const result = await $`git -C ${cwd} ${args}`.env(gitEnv()).quiet().nothrow()
112
+ if (result.exitCode !== 0) return null
113
+ const text = result.stdout.toString().trim()
114
+ return text.length > 0 ? text : null
115
+ }
116
+
117
+ async function hasCommit(gitArgs: string[], sha: string): Promise<boolean> {
118
+ const result = await $`git ${gitArgs} cat-file -e ${`${sha}^{commit}`}`
119
+ .env(gitEnv())
120
+ .quiet()
121
+ .nothrow()
122
+ return result.exitCode === 0
123
+ }
124
+
125
+ /**
126
+ * The main worktree root of the repository at `cwd`, read off the shared git
127
+ * directory so a linked worktree answers with the checkout its records sit at.
128
+ */
129
+ async function repositoryRoot(cwd: string): Promise<string> {
130
+ const common = await gitRead(cwd, [
131
+ 'rev-parse',
132
+ '--path-format=absolute',
133
+ '--git-common-dir',
134
+ ])
135
+ if (common === null) return cwd
136
+ return basename(common) === '.git' ? dirname(common) : common
137
+ }
138
+
139
+ async function originIdentity(root: string): Promise<string | null> {
140
+ const url = await gitRead(root, ['remote', 'get-url', 'origin'])
141
+ return url === null ? null : remoteIdentity(url)
142
+ }
143
+
144
+ function recordsGitDir(root: string): string {
145
+ return join(recordRoot(root), '.records.git')
146
+ }
147
+
148
+ async function recordsHead(root: string): Promise<string | null> {
149
+ const dir = recordsGitDir(root)
150
+ if (!existsSync(dir)) return null
151
+ return gitRead(root, [`--git-dir=${dir}`, 'rev-parse', 'HEAD'])
152
+ }
153
+
154
+ /**
155
+ * The newest compact note modified since the transcript's first timestamp.
156
+ * A note older than the session was written for a different one.
157
+ */
158
+ function newestHandoff(root: string, startedAt: string | null): string | null {
159
+ const dir = recordDir(root, 'compact')
160
+ if (!existsSync(dir)) return null
161
+
162
+ const since = startedAt === null ? 0 : Date.parse(startedAt)
163
+ let newest: { path: string; at: number } | null = null
164
+
165
+ for (const name of readdirSync(dir)) {
166
+ if (!name.endsWith('.md')) continue
167
+ const path = join(dir, name)
168
+ const at = statSync(path).mtimeMs
169
+ if (at >= since && (newest === null || at > newest.at)) {
170
+ newest = { path, at }
171
+ }
172
+ }
173
+
174
+ return newest === null ? null : newest.path
175
+ }
176
+
177
+ async function isCommittedInRecords(
178
+ root: string,
179
+ note: string,
180
+ ): Promise<boolean> {
181
+ const dir = recordsGitDir(root)
182
+ if (!existsSync(dir)) return false
183
+ const inside = relative(recordRoot(root), note)
184
+ const result = await $`git --git-dir=${dir} cat-file -e ${`HEAD:${inside}`}`
185
+ .env(gitEnv())
186
+ .quiet()
187
+ .nothrow()
188
+ return result.exitCode === 0
189
+ }
190
+
191
+ export interface ExportOptions {
192
+ readonly id: string
193
+ /** Where the repository facts are read. Defaults to the working directory. */
194
+ readonly cwd?: string
195
+ readonly projects?: string
196
+ readonly out?: string
197
+ readonly isLive: (id: string) => Promise<boolean>
198
+ }
199
+
200
+ export type ExportOutcome =
201
+ | {
202
+ readonly ok: true
203
+ readonly path: string
204
+ readonly manifest: Manifest
205
+ readonly files: readonly string[]
206
+ }
207
+ | TransferRefused
208
+
209
+ /**
210
+ * Packs a session's transcript, its side folder with paths kept, and a
211
+ * manifest into one gzip tarball on local disk. Nothing is uploaded.
212
+ */
213
+ export async function exportSession(
214
+ options: ExportOptions,
215
+ ): Promise<ExportOutcome> {
216
+ const unavailable = archiveUnavailable()
217
+ if (unavailable) return unavailable
218
+
219
+ const location = locateTranscript(
220
+ options.id,
221
+ options.projects ?? projectsDir(),
222
+ )
223
+ if (location === null) {
224
+ return refuse(
225
+ 'not-found',
226
+ `No transcript for ${options.id} under ${options.projects ?? projectsDir()}. Check the id with canon sessions list --json, which carries sessionId per row.`,
227
+ )
228
+ }
229
+
230
+ const transcript = readTranscript(location.transcript)
231
+ const facts = transcriptFacts(transcript)
232
+ // The branch and HEAD come from the checkout the caller stands in, which is
233
+ // the linked worktree a worker builds in, while the records and the handoff
234
+ // sit at the main root every worktree shares.
235
+ const cwd = options.cwd ?? process.cwd()
236
+ const root = await repositoryRoot(cwd)
237
+ const handoffPath = newestHandoff(root, facts.startedAt)
238
+
239
+ const manifest: Manifest = {
240
+ format: BUNDLE_FORMAT,
241
+ sessionId: options.id,
242
+ title: facts.title,
243
+ cwd: facts.cwd,
244
+ root,
245
+ origin: await originIdentity(root),
246
+ branch: await gitRead(cwd, ['branch', '--show-current']),
247
+ head: await gitRead(cwd, ['rev-parse', 'HEAD']),
248
+ recordsHead: await recordsHead(root),
249
+ compacted: facts.compacted,
250
+ live: await options.isLive(options.id),
251
+ handoff: handoffPath === null ? null : relative(root, handoffPath),
252
+ handoffCommitted:
253
+ handoffPath !== null && (await isCommittedInRecords(root, handoffPath)),
254
+ recommend: handoffPath === null ? 'transcript' : 'handoff',
255
+ }
256
+
257
+ const side = readSideFolder(location.side)
258
+ const entries: Record<string, Uint8Array | string> = {
259
+ [MANIFEST_PATH]: `${JSON.stringify(manifest, null, 2)}\n`,
260
+ [`${SESSION_FOLDER}/${options.id}.jsonl`]: transcript,
261
+ }
262
+ for (const file of side) {
263
+ entries[`${SESSION_FOLDER}/${options.id}/${file}`] = readFileSync(
264
+ join(location.side, file),
265
+ )
266
+ }
267
+
268
+ const path =
269
+ options.out ??
270
+ recordDir(root, SCRATCH, 'session-export', `${options.id}.tar.gz`)
271
+ mkdirSync(dirname(path), { recursive: true })
272
+ const archive = new Bun.Archive(entries, { compress: 'gzip' })
273
+ writeFileSync(path, await archive.bytes())
274
+
275
+ return { ok: true, path, manifest, files: side }
276
+ }
277
+
278
+ interface Unpacked {
279
+ readonly manifest: Manifest
280
+ readonly transcript: Uint8Array
281
+ /** Side folder files keyed by their path relative to the side folder. */
282
+ readonly side: ReadonlyMap<string, Uint8Array>
283
+ }
284
+
285
+ /**
286
+ * Whether an archive entry name stays inside the folder it is written under.
287
+ * A bundle is a file handed over from another machine, so its names are input.
288
+ */
289
+ function isContained(name: string): boolean {
290
+ return (
291
+ !isAbsolute(name) &&
292
+ !name.includes('\\') &&
293
+ !name.split('/').some((segment) => segment === '..' || segment === '')
294
+ )
295
+ }
296
+
297
+ async function unpack(bytes: Uint8Array): Promise<Unpacked | TransferRefused> {
298
+ let files: Map<string, File>
299
+ try {
300
+ files = await new Bun.Archive(bytes).files()
301
+ } catch {
302
+ return refuse('not-a-bundle', 'The file does not read as a gzip tarball.')
303
+ }
304
+
305
+ const raw = files.get(MANIFEST_PATH)
306
+ let manifest: unknown
307
+ try {
308
+ manifest = raw ? JSON.parse(await raw.text()) : undefined
309
+ } catch {
310
+ manifest = undefined
311
+ }
312
+ if (!isManifest(manifest)) {
313
+ return refuse(
314
+ 'not-a-bundle',
315
+ `The archive carries no ${MANIFEST_PATH} this binary reads, or one at a format other than ${BUNDLE_FORMAT}.`,
316
+ )
317
+ }
318
+
319
+ const transcriptName = `${SESSION_FOLDER}/${manifest.sessionId}.jsonl`
320
+ const sidePrefix = `${SESSION_FOLDER}/${manifest.sessionId}/`
321
+ let transcript: Uint8Array | undefined
322
+ const side = new Map<string, Uint8Array>()
323
+
324
+ for (const [name, file] of files) {
325
+ if (name === MANIFEST_PATH) continue
326
+ const inside = name.startsWith(sidePrefix)
327
+ ? name.slice(sidePrefix.length)
328
+ : null
329
+
330
+ if (name === transcriptName) {
331
+ transcript = await file.bytes()
332
+ } else if (inside !== null && isContained(inside)) {
333
+ side.set(inside, await file.bytes())
334
+ } else {
335
+ return refuse(
336
+ 'not-a-bundle',
337
+ `The archive carries ${JSON.stringify(name)}, which sits outside the session it names. Nothing was written.`,
338
+ )
339
+ }
340
+ }
341
+
342
+ if (transcript === undefined) {
343
+ return refuse(
344
+ 'not-a-bundle',
345
+ `The archive carries no ${transcriptName}, so there is no transcript to place.`,
346
+ )
347
+ }
348
+
349
+ return { manifest, transcript, side }
350
+ }
351
+
352
+ /** Removes a directory tree bottom-up, keeping any folder that still holds a file. */
353
+ function pruneEmpty(dir: string): void {
354
+ let entries
355
+ try {
356
+ entries = readdirSync(dir, { withFileTypes: true })
357
+ } catch {
358
+ return
359
+ }
360
+ for (const entry of entries) {
361
+ if (entry.isDirectory()) pruneEmpty(join(dir, entry.name))
362
+ }
363
+ try {
364
+ rmdirSync(dir)
365
+ } catch {
366
+ // Not empty, so something besides this run's files sits there.
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Removes the files an import wrote and the folders it created, and returns
372
+ * whatever could not be removed. A folder is removed only once empty, so a
373
+ * file some other writer placed there in the meantime survives.
374
+ */
375
+ function rollBack(
376
+ written: readonly string[],
377
+ created: readonly string[],
378
+ ): string[] {
379
+ const left: string[] = []
380
+ for (const path of written) {
381
+ try {
382
+ unlinkSync(path)
383
+ } catch {
384
+ left.push(path)
385
+ }
386
+ }
387
+ for (const dir of [...created].reverse()) pruneEmpty(dir)
388
+ return left
389
+ }
390
+
391
+ export interface ImportOptions {
392
+ readonly bundle: string
393
+ /** The repository the session resumes in, which decides the encoded folder. */
394
+ readonly root: string
395
+ readonly projects?: string
396
+ readonly isLive: (id: string) => Promise<boolean>
397
+ }
398
+
399
+ export type ImportOutcome =
400
+ | {
401
+ readonly ok: true
402
+ readonly manifest: Manifest
403
+ readonly folder: string
404
+ readonly transcript: string
405
+ readonly files: readonly string[]
406
+ readonly behind: readonly Behind[]
407
+ readonly routes: readonly string[]
408
+ }
409
+ | TransferRefused
410
+
411
+ /**
412
+ * Places a bundle's transcript and side folder under the local encoding of
413
+ * `root`, refusing rather than overwriting anything already there.
414
+ */
415
+ export async function importSession(
416
+ options: ImportOptions,
417
+ ): Promise<ImportOutcome> {
418
+ const unavailable = archiveUnavailable()
419
+ if (unavailable) return unavailable
420
+
421
+ let bytes: Uint8Array
422
+ try {
423
+ bytes = readFileSync(options.bundle)
424
+ } catch {
425
+ return refuse('not-a-bundle', `No readable file at ${options.bundle}.`)
426
+ }
427
+
428
+ const unpacked = await unpack(bytes)
429
+ if ('ok' in unpacked) return unpacked
430
+ const { manifest } = unpacked
431
+ const id = manifest.sessionId
432
+
433
+ const origin = await originIdentity(options.root)
434
+ if (origin !== manifest.origin) {
435
+ return refuse(
436
+ 'other-repository',
437
+ `The bundle came from ${manifest.origin ?? 'a repository with no origin'}, and ${options.root} is ${origin ?? 'a repository with no origin'}. Import it inside a checkout of the same repository.`,
438
+ )
439
+ }
440
+
441
+ if (await options.isLive(id)) {
442
+ return refuse(
443
+ 'live-here',
444
+ `Session ${id} is live on this machine, so placing a second copy would fork it. End that session first.`,
445
+ )
446
+ }
447
+
448
+ const projects = options.projects ?? projectsDir()
449
+ const folder = join(projects, encodeProjectPath(options.root))
450
+ const transcript = join(folder, `${id}.jsonl`)
451
+ const sideDir = join(folder, id)
452
+ // A copy under any other project folder counts too, since `--resume <id>`
453
+ // walks every folder and would pick whichever copy it reached first.
454
+ const elsewhere = locateTranscript(id, projects)?.transcript
455
+ const taken = [
456
+ ...new Set(
457
+ [transcript, sideDir, elsewhere].filter(
458
+ (path): path is string => path !== undefined && existsSync(path),
459
+ ),
460
+ ),
461
+ ]
462
+ if (taken.length > 0) {
463
+ return refuse(
464
+ 'exists',
465
+ `Already present: ${taken.join(', ')}. Nothing was written, so move the existing copy aside if the bundle should replace it.`,
466
+ )
467
+ }
468
+
469
+ const behind: Behind[] = []
470
+ if (
471
+ manifest.head !== null &&
472
+ !(await hasCommit(['-C', options.root], manifest.head))
473
+ ) {
474
+ behind.push('repository-behind')
475
+ }
476
+ if (manifest.recordsHead !== null) {
477
+ const dir = recordsGitDir(options.root)
478
+ if (
479
+ !existsSync(dir) ||
480
+ !(await hasCommit([`--git-dir=${dir}`], manifest.recordsHead))
481
+ ) {
482
+ behind.push('records-behind')
483
+ }
484
+ }
485
+
486
+ // The existence check above and these writes are not atomic, so `wx` is what
487
+ // keeps a copy that appeared in between from being overwritten. A failure
488
+ // partway removes what this run placed, since a leftover would make the next
489
+ // import of the same bundle refuse on it.
490
+ const written: string[] = []
491
+ const created: string[] = []
492
+ // `mkdir` over a file raises EEXIST too, so the code alone cannot say a copy
493
+ // appeared. Only a `wx` write refusing an existing path means that.
494
+ let isWriting = false
495
+ const makeDir = (dir: string): void => {
496
+ const first = mkdirSync(dir, { recursive: true })
497
+ if (first !== undefined) created.push(first)
498
+ }
499
+ const place = (path: string, content: Uint8Array): void => {
500
+ makeDir(dirname(path))
501
+ isWriting = true
502
+ writeFileSync(path, content, { flag: 'wx' })
503
+ isWriting = false
504
+ written.push(path)
505
+ }
506
+ try {
507
+ place(transcript, unpacked.transcript)
508
+ for (const [name, content] of unpacked.side) {
509
+ place(join(sideDir, name), content)
510
+ }
511
+ } catch (error) {
512
+ const left = rollBack(written, created)
513
+ const cause = error instanceof Error ? error.message : String(error)
514
+ const residue =
515
+ left.length === 0
516
+ ? 'Everything this run wrote was removed.'
517
+ : `These could not be removed, so move them aside before retrying: ${left.join(', ')}.`
518
+ const isTaken =
519
+ isWriting &&
520
+ error instanceof Error &&
521
+ 'code' in error &&
522
+ error.code === 'EEXIST'
523
+ return isTaken
524
+ ? refuse(
525
+ 'exists',
526
+ `A file appeared under ${folder} while the import was writing, so it stopped rather than overwrite it: ${cause}. ${residue}`,
527
+ )
528
+ : refuse(
529
+ 'write-failed',
530
+ `The import could not write under ${folder}: ${cause}. ${residue}`,
531
+ )
532
+ }
533
+
534
+ return {
535
+ ok: true,
536
+ manifest,
537
+ folder,
538
+ transcript,
539
+ files: [...unpacked.side.keys()],
540
+ behind,
541
+ routes: [
542
+ `cd ${options.root} && claude --resume ${id}`,
543
+ `cd ${options.root} && claude "/canon:session-resume"`,
544
+ ],
545
+ }
546
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+ import { claudeConfigDir } from '@/sessions/registry'
4
+
5
+ /**
6
+ * The folder holding one subfolder per project the client has run in, each
7
+ * carrying that project's transcripts.
8
+ */
9
+ export function projectsDir(): string {
10
+ return join(claudeConfigDir(), 'projects')
11
+ }
12
+
13
+ /**
14
+ * The subfolder name the client files a project's transcripts under.
15
+ *
16
+ * Inferred from observed folders rather than read from a published rule:
17
+ * every character outside `[A-Za-z0-9]` becomes a dash, so a dotted segment
18
+ * doubles the dash before it. How a client shortens a path past its length
19
+ * limit was never observed, so a very long path may land where `--continue`
20
+ * does not look, while `--resume <id>` still finds it by walking every folder.
21
+ */
22
+ export function encodeProjectPath(path: string): string {
23
+ return path.replace(/[^A-Za-z0-9]/g, '-')
24
+ }
25
+
26
+ export interface TranscriptLocation {
27
+ /** The project subfolder the transcript sits in. */
28
+ readonly folder: string
29
+ readonly transcript: string
30
+ /** Where the client keeps the session's tool output and title, present or not. */
31
+ readonly side: string
32
+ }
33
+
34
+ /**
35
+ * A session id is a plain identifier, and admitting anything else would let
36
+ * the id itself walk out of the projects folder.
37
+ */
38
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9-]*$/
39
+
40
+ export function isSessionId(id: string): boolean {
41
+ return SESSION_ID.test(id)
42
+ }
43
+
44
+ /**
45
+ * Finds a session's transcript under any project subfolder.
46
+ *
47
+ * The walk crosses every folder rather than deriving one from the working
48
+ * directory, since a transcript copied from another machine sits under that
49
+ * machine's encoding and `--resume <id>` was measured to find it there.
50
+ */
51
+ export function locateTranscript(
52
+ id: string,
53
+ dir: string = projectsDir(),
54
+ ): TranscriptLocation | null {
55
+ if (!isSessionId(id)) return null
56
+
57
+ let folders: string[]
58
+ try {
59
+ folders = readdirSync(dir)
60
+ } catch {
61
+ return null
62
+ }
63
+
64
+ for (const name of folders) {
65
+ const transcript = join(dir, name, `${id}.jsonl`)
66
+ if (existsSync(transcript)) {
67
+ return { folder: join(dir, name), transcript, side: join(dir, name, id) }
68
+ }
69
+ }
70
+
71
+ return null
72
+ }
73
+
74
+ /**
75
+ * Every file under a side folder by its path relative to it, or none when it
76
+ * is absent. Paths use `/` on every platform, since they become archive entry
77
+ * names and an import refuses a backslash as a path it cannot contain.
78
+ */
79
+ export function readSideFolder(side: string): string[] {
80
+ if (!existsSync(side)) return []
81
+
82
+ return readdirSync(side, { recursive: true, withFileTypes: true })
83
+ .filter((entry) => entry.isFile())
84
+ .map((entry) =>
85
+ relative(side, join(entry.parentPath, entry.name)).split(sep).join('/'),
86
+ )
87
+ }
88
+
89
+ const NEWLINE = 0x0a
90
+
91
+ /**
92
+ * Reads a transcript up to its last newline.
93
+ *
94
+ * The client appends a line per event, so a session exporting itself is
95
+ * mid-write, and a copy taken at that moment can end on half a line. Cutting
96
+ * at the last newline keeps every whole line and never rewrites one.
97
+ */
98
+ export function readTranscript(path: string): Uint8Array {
99
+ const bytes = readFileSync(path)
100
+ const end = bytes.lastIndexOf(NEWLINE)
101
+ return end === -1 ? new Uint8Array() : bytes.subarray(0, end + 1)
102
+ }
103
+
104
+ export interface TranscriptFacts {
105
+ readonly title: string | null
106
+ readonly cwd: string | null
107
+ readonly startedAt: string | null
108
+ readonly compacted: boolean
109
+ }
110
+
111
+ /**
112
+ * Reads the few facts a manifest carries off a transcript's lines, without
113
+ * changing any of them. The format belongs to the client, so a line that does
114
+ * not parse is skipped rather than reported.
115
+ */
116
+ export function transcriptFacts(bytes: Uint8Array): TranscriptFacts {
117
+ let title: string | null = null
118
+ let cwd: string | null = null
119
+ let startedAt: string | null = null
120
+ let compacted = false
121
+
122
+ for (const line of new TextDecoder().decode(bytes).split('\n')) {
123
+ if (!line) continue
124
+
125
+ let record: Record<string, unknown>
126
+ try {
127
+ const parsed: unknown = JSON.parse(line)
128
+ if (typeof parsed !== 'object' || parsed === null) continue
129
+ record = parsed as Record<string, unknown>
130
+ } catch {
131
+ continue
132
+ }
133
+
134
+ if (
135
+ record.type === 'custom-title' &&
136
+ typeof record.customTitle === 'string'
137
+ ) {
138
+ title = record.customTitle
139
+ }
140
+ if (typeof record.cwd === 'string') cwd = record.cwd
141
+ if (startedAt === null && typeof record.timestamp === 'string') {
142
+ startedAt = record.timestamp
143
+ }
144
+ if (record.subtype === 'compact_boundary') compacted = true
145
+ }
146
+
147
+ return { title, cwd, startedAt, compacted }
148
+ }