@erclx/canon 4.114.0 → 4.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/teach-workspace/SKILL.md +10 -6
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +4 -2
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/teach.md +26 -4
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +22 -4
- package/src/context/audit.ts +54 -18
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +278 -16
- package/src/teach/render.ts +94 -6
- package/standards/teach.md +13 -12
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
|
@@ -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
|
+
}
|