amorphous-cli 0.1.13 → 0.3.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.
@@ -0,0 +1,541 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ chmodSync, closeSync, existsSync, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync,
4
+ realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
5
+ } from 'node:fs'
6
+ import { homedir, tmpdir } from 'node:os'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
9
+
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
11
+ const BEGIN = '# BEGIN amorphous agent guides'
12
+ const END = '# END amorphous agent guides'
13
+ const GUIDE_MAX_BYTES = 32 * 1024
14
+ const FILES = ['agent.md', 'private-agent.md']
15
+
16
+ /*
17
+ * Filesystem contract for the context command:
18
+ * - guidePaths(root, abstractId, userId) returns active, identity-bound paths;
19
+ * - ensureGuideFilesIgnored(root) installs non-destructive ignore rules;
20
+ * - activateGuideIdentity(root, abstractId, userId) must run only after fresh
21
+ * server authorization and archives files from any prior identity;
22
+ * - readGuideSyncState/writeGuideSyncState store only revisions and body hashes;
23
+ * - materializeGuide({ projectRoot, abstractId, userId, kind, body, revision })
24
+ * writes unchanged-base remote content, leaves local edits intact on conflict,
25
+ * and returns { status, path, conflictPath? }. Handle conflict/identity-uncertain
26
+ * without loading the active file as newly authorized remote context.
27
+ * The active paths are agent.md and private-agent.md. Private sync state and all
28
+ * conflict/recovery/temp artifacts live under ignored .amorphous-guides/.
29
+ */
30
+
31
+ function assertId(value, label) {
32
+ if (typeof value !== 'string' || !UUID.test(value)) throw new Error(`Invalid ${label}.`)
33
+ return value.toLowerCase()
34
+ }
35
+
36
+ function projectRootOf(projectRoot) {
37
+ if (typeof projectRoot !== 'string' || !projectRoot) throw new Error('Project root is required.')
38
+ const requested = resolve(projectRoot)
39
+ const info = lstatSync(requested)
40
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Project root must be a real directory, not a symlink.')
41
+ const actual = realpathSync(requested)
42
+ if (actual === parseRoot(actual) || actual === homedir()) throw new Error('Refusing to use a filesystem or home directory as the project root.')
43
+ return actual
44
+ }
45
+
46
+ function parseRoot(path) {
47
+ return parse(path).root
48
+ }
49
+
50
+ function checkedPath(root, ...parts) {
51
+ const path = resolve(root, ...parts)
52
+ const rel = relative(root, path)
53
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
54
+ if (rel === '') return path
55
+ throw new Error('Guide path escapes the project root.')
56
+ }
57
+ return path
58
+ }
59
+
60
+ function assertNoSymlink(path, { allowMissing = true } = {}) {
61
+ try {
62
+ if (lstatSync(path).isSymbolicLink()) throw new Error(`Symlinked guide path is not allowed: ${path}`)
63
+ } catch (error) {
64
+ if (error?.code === 'ENOENT' && allowMissing) return
65
+ throw error
66
+ }
67
+ }
68
+
69
+ function assertNoSymlinkChain(path) {
70
+ const absolute = resolve(path)
71
+ const root = parseRoot(absolute)
72
+ let current = root
73
+ for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
74
+ current = join(current, part)
75
+ assertNoSymlink(current)
76
+ }
77
+ }
78
+
79
+ function ensureDirectory(root, path, mode = 0o700) {
80
+ const rel = relative(root, path)
81
+ let current = root
82
+ for (const part of rel.split(sep).filter(Boolean)) {
83
+ current = join(current, part)
84
+ assertNoSymlink(current)
85
+ try { mkdirSync(current, { mode }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
86
+ const info = lstatSync(current)
87
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Unsafe guide directory: ${current}`)
88
+ try { chmodSync(current, mode) } catch { /* preserve operation result; target remains private by file mode */ }
89
+ }
90
+ }
91
+
92
+ function ensureIgnoreParent(root, path) {
93
+ const parent = dirname(path)
94
+ const rel = relative(root, parent)
95
+ let current = root
96
+ for (const part of rel.split(sep).filter(Boolean)) {
97
+ current = join(current, part)
98
+ assertNoSymlink(current)
99
+ try { mkdirSync(current) } catch (error) { if (error?.code !== 'EEXIST') throw error }
100
+ const info = lstatSync(current)
101
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Unsafe guide ignore directory: ${current}`)
102
+ }
103
+ }
104
+
105
+ function sha256(value) {
106
+ return createHash('sha256').update(value).digest('hex')
107
+ }
108
+
109
+ function git(root, args, options = {}) {
110
+ return spawnSync('git', args, { cwd: root, encoding: 'utf8', ...options })
111
+ }
112
+
113
+ function assertArtifactPathsSafeToWrite(root, absolutePaths) {
114
+ const paths = absolutePaths.map((path) => {
115
+ const rel = relative(root, resolve(path)).split(sep).join('/')
116
+ if (!rel || rel === '..' || rel.startsWith('../')) throw new Error('Guide artifact path escapes project root.')
117
+ return rel
118
+ })
119
+ const repo = git(root, ['rev-parse', '--show-toplevel'])
120
+ if (repo.error) throw new Error('Cannot verify guide artifact ignore/tracking status; refusing to write guide context.')
121
+ if (repo.status === 0) {
122
+ const gitRoot = realpathSync(repo.stdout.trim())
123
+ for (const path of paths) {
124
+ const repoPath = relative(gitRoot, resolve(root, path)).split(sep).join('/')
125
+ const tracked = git(gitRoot, ['ls-files', '--', repoPath])
126
+ const ignored = git(gitRoot, ['check-ignore', '--quiet', '--', repoPath])
127
+ if (tracked.status !== 0 || tracked.stdout.trim() || ignored.status !== 0) {
128
+ throw new Error('Guide artifact path is tracked or not effectively ignored; refusing to write guide context.')
129
+ }
130
+ }
131
+ return
132
+ }
133
+ if (repo.status !== 128) throw new Error('Cannot verify guide artifact ignore status; refusing to write guide context.')
134
+
135
+ // A disposable repo can model the root .gitignore but not assume that no
136
+ // deeper .gitignore changes the rules. Fail closed for artifacts beneath
137
+ // any nested ignore file rather than materializing content under uncertain
138
+ // effective rules.
139
+ for (const path of paths) {
140
+ let directory = dirname(resolve(root, path))
141
+ while (directory !== root) {
142
+ const nestedIgnore = join(directory, '.gitignore')
143
+ assertNoSymlinkChain(nestedIgnore)
144
+ try {
145
+ const info = lstatSync(nestedIgnore)
146
+ if (info.isSymbolicLink() || !info.isFile()) {
147
+ throw new Error('Nested guide ignore path is unsafe; refusing to write guide context.')
148
+ }
149
+ throw new Error('Nested guide ignore rules cannot be proven safe outside Git; refusing to write guide context.')
150
+ } catch (error) {
151
+ if (error?.code !== 'ENOENT') throw error
152
+ }
153
+ directory = dirname(directory)
154
+ }
155
+ }
156
+
157
+ const temp = mkdtempSync(join(tmpdir(), 'am-guide-artifact-check-'))
158
+ try {
159
+ const init = git(temp, ['init', '--quiet'])
160
+ if (init.status !== 0) throw new Error('Cannot verify guide artifact ignore status; refusing to write guide context.')
161
+ let ignoreText = ''
162
+ try { ignoreText = readFileSync(join(root, '.gitignore'), 'utf8') } catch (error) { if (error?.code !== 'ENOENT') throw error }
163
+ writeFileSync(join(temp, '.gitignore'), ignoreText)
164
+ for (const path of paths) {
165
+ const ignored = git(temp, ['check-ignore', '--quiet', '--', path])
166
+ if (ignored.status !== 0) throw new Error('Guide artifact path is not effectively ignored; refusing to write guide context.')
167
+ }
168
+ } finally { rmSync(temp, { recursive: true, force: true }) }
169
+ }
170
+
171
+ function assertGuidePathsSafeToWrite(root) {
172
+ const repo = git(root, ['rev-parse', '--show-toplevel'])
173
+ if (repo.error) throw new Error('Cannot verify private guide ignore and tracking status; refusing to write private context.')
174
+ if (repo.status === 0) {
175
+ const gitRoot = realpathSync(repo.stdout.trim())
176
+ const trackedPaths = ['agent.md', 'private-agent.md', '.amorphous-guides']
177
+ .map((path) => relative(gitRoot, resolve(root, path)).split(sep).join('/'))
178
+ for (const path of trackedPaths) {
179
+ const tracked = git(gitRoot, ['ls-files', '--', path])
180
+ if (tracked.status !== 0 || tracked.stdout.trim()) {
181
+ throw new Error('Agent guide paths are tracked or tracking status is unknown; refusing to write guide context.')
182
+ }
183
+ }
184
+ const ignoredPaths = ['agent.md', 'private-agent.md', '.amorphous-guides/private-check']
185
+ .map((path) => relative(gitRoot, resolve(root, path)).split(sep).join('/'))
186
+ for (const path of ignoredPaths) {
187
+ const ignored = git(gitRoot, ['check-ignore', '--quiet', '--', path])
188
+ if (ignored.status !== 0) {
189
+ throw new Error('Agent guide paths are not effectively ignored; refusing to write guide context.')
190
+ }
191
+ }
192
+ return
193
+ }
194
+ if (repo.status !== 128) throw new Error('Cannot verify guide ignore status; refusing to write guide context.')
195
+
196
+ // Outside Git, check the workspace's .gitignore in a disposable real repo so
197
+ // negations and directory semantics are evaluated by Git itself.
198
+ const temp = mkdtempSync(join(tmpdir(), 'am-guide-ignore-check-'))
199
+ try {
200
+ const init = git(temp, ['init', '--quiet'])
201
+ if (init.status !== 0) throw new Error('Cannot verify guide ignore status; refusing to write guide context.')
202
+ let ignoreText = ''
203
+ try { ignoreText = readFileSync(join(root, '.gitignore'), 'utf8') } catch (error) { if (error?.code !== 'ENOENT') throw error }
204
+ writeFileSync(join(temp, '.gitignore'), ignoreText)
205
+ for (const path of ['agent.md', 'private-agent.md', '.amorphous-guides/private-check']) {
206
+ const ignored = git(temp, ['check-ignore', '--quiet', '--', path])
207
+ if (ignored.status !== 0) throw new Error('Agent guide paths are not effectively ignored; refusing to write guide context.')
208
+ }
209
+ } finally { rmSync(temp, { recursive: true, force: true }) }
210
+ }
211
+
212
+ function atomicWrite(path, data, mode = 0o600, safetyRoot = null) {
213
+ const parent = dirname(path)
214
+ assertNoSymlink(parent, { allowMissing: false })
215
+ assertNoSymlink(path)
216
+ const temp = join(parent, `.amorphous-guide-${process.pid}-${randomUUID()}.tmp`)
217
+ if (safetyRoot) assertArtifactPathsSafeToWrite(safetyRoot, [path, temp])
218
+ let fd
219
+ try {
220
+ fd = openSync(temp, 'wx', mode)
221
+ writeFileSync(fd, data)
222
+ fchmodSync(fd, mode)
223
+ closeSync(fd)
224
+ fd = undefined
225
+ assertNoSymlink(path)
226
+ renameSync(temp, path)
227
+ chmodSync(path, mode)
228
+ } catch (error) {
229
+ if (fd !== undefined) closeSync(fd)
230
+ try { unlinkSync(temp) } catch { /* no temporary file remains */ }
231
+ throw error
232
+ }
233
+ }
234
+
235
+ function readJsonFile(path) {
236
+ assertNoSymlinkChain(dirname(path))
237
+ assertNoSymlink(path)
238
+ try {
239
+ const info = lstatSync(path)
240
+ if (!info.isFile()) throw new Error(`Expected a regular guide metadata file: ${path}`)
241
+ return JSON.parse(readFileSync(path, 'utf8'))
242
+ } catch (error) {
243
+ if (error?.code === 'ENOENT') return null
244
+ throw error
245
+ }
246
+ }
247
+
248
+ function validRevisionEntry(entry) {
249
+ return entry && typeof entry === 'object' && !Array.isArray(entry) &&
250
+ Number.isSafeInteger(entry.revision) && entry.revision >= 0 &&
251
+ typeof entry.hash === 'string' && /^[a-f0-9]{64}$/.test(entry.hash)
252
+ }
253
+
254
+ function validateState(value, abstractId, userId) {
255
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== 1 ||
256
+ value.abstractId !== abstractId || value.userId !== userId || !value.guides ||
257
+ typeof value.guides !== 'object' || Array.isArray(value.guides) ||
258
+ Object.keys(value).some((key) => !['schemaVersion', 'abstractId', 'userId', 'guides'].includes(key))) {
259
+ throw new Error('Invalid guide sync state identity or format.')
260
+ }
261
+ for (const key of Object.keys(value.guides)) {
262
+ if (!['abstract', 'private'].includes(key) || !validRevisionEntry(value.guides[key]) ||
263
+ Object.keys(value.guides[key]).some((entryKey) => !['revision', 'hash'].includes(entryKey))) {
264
+ throw new Error('Invalid guide sync state metadata.')
265
+ }
266
+ }
267
+ return value
268
+ }
269
+
270
+ /** Return validated local paths for one abstract and authenticated user. */
271
+ export function guidePaths(projectRoot, abstractId, userId) {
272
+ const root = projectRootOf(projectRoot)
273
+ const app = assertId(abstractId, 'abstract id')
274
+ const user = assertId(userId, 'user id')
275
+ const internal = (...parts) => checkedPath(root, '.amorphous-guides', ...parts)
276
+ return {
277
+ projectRoot: root,
278
+ abstractId: app,
279
+ userId: user,
280
+ abstractGuide: checkedPath(root, 'agent.md'),
281
+ privateGuide: checkedPath(root, 'private-agent.md'),
282
+ activeIdentity: internal('active.json'),
283
+ syncState: internal('state', app, user, 'sync.json'),
284
+ conflictsDirectory: internal('conflicts', app, user),
285
+ recoveryDirectory: internal('recovery', app, user),
286
+ tempDirectory: internal('tmp'),
287
+ }
288
+ }
289
+
290
+ /** SHA-256 of the exact UTF-8 guide body, for state comparisons only. */
291
+ export function guideBodyHash(body) {
292
+ if (typeof body !== 'string') throw new Error('Guide body must be text.')
293
+ return sha256(body)
294
+ }
295
+
296
+ function gitIgnoreTarget(root) {
297
+ let current = root
298
+ for (;;) {
299
+ const dotGit = join(current, '.git')
300
+ assertNoSymlink(dotGit)
301
+ try {
302
+ const info = lstatSync(dotGit)
303
+ if (info.isDirectory()) return { path: join(dotGit, 'info', 'exclude'), gitRoot: current }
304
+ if (info.isFile()) {
305
+ const text = readFileSync(dotGit, 'utf8').trim()
306
+ const match = /^gitdir:\s*(.+)$/.exec(text)
307
+ if (match) {
308
+ const gitDir = resolve(current, match[1])
309
+ assertNoSymlinkChain(gitDir)
310
+ if (!lstatSync(gitDir).isDirectory()) throw new Error('Invalid Git directory for guide ignores.')
311
+ return { path: join(gitDir, 'info', 'exclude'), gitRoot: current, worktree: true }
312
+ }
313
+ }
314
+ } catch (error) { if (error?.code !== 'ENOENT') throw error }
315
+ const parent = dirname(current)
316
+ if (parent === current) return null
317
+ current = parent
318
+ }
319
+ }
320
+
321
+ function ignoreBlock(prefix) {
322
+ const encodedPrefix = prefix ? prefix.split('/').map((part) => part
323
+ .replace(/([\\*?\[\]])/g, '\\$1')
324
+ .replace(/ /g, '\\ ')
325
+ .replace(/^([#!])/, '\\$1')).join('/') : ''
326
+ const base = encodedPrefix ? `/${encodedPrefix}/` : '/'
327
+ return `${BEGIN}\n${base}agent.md\n${base}private-agent.md\n${base}.amorphous-guide-*.tmp\n${base}.amorphous-guides/\n${END}`
328
+ }
329
+
330
+ function managedBlock(text, block) {
331
+ const begin = text.indexOf(BEGIN)
332
+ const end = text.indexOf(END)
333
+ if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin)) throw new Error('Malformed managed guide ignore block.')
334
+ if (begin < 0) return `${text}${text && !text.endsWith('\n') ? '\n' : ''}${block}\n`
335
+ const endAfter = end + END.length
336
+ if (text.indexOf(BEGIN, begin + BEGIN.length) >= 0 || text.indexOf(END, endAfter) >= 0) {
337
+ throw new Error('Duplicate managed guide ignore block.')
338
+ }
339
+ const existingEntries = text.slice(begin + BEGIN.length, end).split('\n').map((line) => line.trim()).filter(Boolean)
340
+ const proposedEntries = block.split('\n').slice(1, -1)
341
+ const entries = [...new Set([...existingEntries, ...proposedEntries])]
342
+ return `${text.slice(0, begin)}${[BEGIN, ...entries, END].join('\n')}${text.slice(endAfter)}`
343
+ }
344
+
345
+ /** Add private guide/cache ignores without replacing user-authored ignore rules. */
346
+ export function ensureGuideFilesIgnored(projectRoot) {
347
+ const root = projectRootOf(projectRoot)
348
+ const git = gitIgnoreTarget(root)
349
+ // A real .git directory has repo-root-relative excludes. A worktree pointer
350
+ // uses a workspace-local .gitignore fallback so its paths remain scoped.
351
+ const path = git && !git.worktree ? git.path : checkedPath(root, '.gitignore')
352
+ const ignoreRoot = git && !git.worktree ? git.gitRoot : root
353
+ const prefix = relative(ignoreRoot, root).split(sep).filter(Boolean).join('/')
354
+ assertNoSymlinkChain(dirname(path))
355
+ if (relative(root, dirname(path)).startsWith(`..${sep}`)) {
356
+ if (!existsSync(dirname(path))) throw new Error('Git ignore directory is unavailable.')
357
+ } else {
358
+ ensureIgnoreParent(root, path)
359
+ }
360
+ assertNoSymlink(path)
361
+ let current = ''
362
+ try {
363
+ const info = lstatSync(path)
364
+ if (!info.isFile()) throw new Error('Guide ignore target must be a regular file.')
365
+ current = readFileSync(path, 'utf8')
366
+ } catch (error) { if (error?.code !== 'ENOENT') throw error }
367
+ const next = managedBlock(current, ignoreBlock(prefix))
368
+ if (next !== current) {
369
+ let mode = 0o644
370
+ try { mode = lstatSync(path).mode & 0o777 } catch { /* use normal ignore-file permissions */ }
371
+ atomicWrite(path, next, mode)
372
+ }
373
+ return path
374
+ }
375
+
376
+ /** Load metadata only when its embedded abstract/user identity matches the requested key. */
377
+ export function readGuideSyncState(projectRoot, abstractId, userId) {
378
+ const paths = guidePaths(projectRoot, abstractId, userId)
379
+ let value
380
+ try { value = readJsonFile(paths.syncState) } catch (error) {
381
+ if (error instanceof SyntaxError) throw new Error('Invalid guide sync state JSON.')
382
+ throw error
383
+ }
384
+ if (value === null) return null
385
+ return validateState(value, paths.abstractId, paths.userId)
386
+ }
387
+
388
+ /** Persist only revision/hash metadata under a path keyed by abstract and authenticated user. */
389
+ export function writeGuideSyncState(projectRoot, abstractId, userId, guides) {
390
+ const paths = guidePaths(projectRoot, abstractId, userId)
391
+ ensureGuideFilesIgnored(paths.projectRoot)
392
+ assertGuidePathsSafeToWrite(paths.projectRoot)
393
+ if (!guides || typeof guides !== 'object' || Array.isArray(guides)) throw new Error('Guide state is required.')
394
+ const clean = {}
395
+ for (const [key, value] of Object.entries(guides)) {
396
+ if (!['abstract', 'private'].includes(key) || !validRevisionEntry(value)) throw new Error('Invalid guide revision metadata.')
397
+ clean[key] = { revision: value.revision, hash: value.hash }
398
+ }
399
+ const state = { schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId, guides: clean }
400
+ validateState(state, paths.abstractId, paths.userId)
401
+ ensureDirectory(paths.projectRoot, dirname(paths.syncState))
402
+ const existing = readJsonFile(paths.syncState)
403
+ if (existing && JSON.stringify(validateState(existing, paths.abstractId, paths.userId)) === JSON.stringify(state)) return paths.syncState
404
+ atomicWrite(paths.syncState, `${JSON.stringify(state, null, 2)}\n`, 0o600, paths.projectRoot)
405
+ return paths.syncState
406
+ }
407
+
408
+ function activeIdentity(paths) {
409
+ let value
410
+ try { value = readJsonFile(paths.activeIdentity) } catch (error) {
411
+ if (error instanceof SyntaxError) return { value: null, malformed: true }
412
+ throw error
413
+ }
414
+ if (value === null) return { value: null, malformed: false }
415
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== 1 ||
416
+ !UUID.test(value.abstractId || '') || !UUID.test(value.userId || '')) return { value: null, malformed: true }
417
+ return { value: { abstractId: value.abstractId.toLowerCase(), userId: value.userId.toLowerCase() }, malformed: false }
418
+ }
419
+
420
+ function validateActiveGuidePaths(paths) {
421
+ for (const path of [paths.abstractGuide, paths.privateGuide]) {
422
+ assertNoSymlinkChain(dirname(path))
423
+ assertNoSymlink(path)
424
+ try {
425
+ if (!lstatSync(path).isFile()) throw new Error(`Active guide must be a regular file: ${path}`)
426
+ } catch (error) {
427
+ if (error?.code !== 'ENOENT') throw error
428
+ }
429
+ }
430
+ }
431
+
432
+ function recoverActiveFile(root, activePath, recoveryDir, filename) {
433
+ assertNoSymlinkChain(dirname(activePath))
434
+ assertNoSymlink(activePath)
435
+ let info
436
+ try { info = lstatSync(activePath) } catch (error) { if (error?.code === 'ENOENT') return null; throw error }
437
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Unsafe active guide file: ${activePath}`)
438
+ ensureDirectory(root, recoveryDir)
439
+ const name = `${Date.now()}-${randomUUID()}-${filename}`
440
+ const destination = checkedPath(root, relative(root, recoveryDir), name)
441
+ const temp = join(dirname(destination), `.amorphous-guide-${process.pid}-recovery-preflight.tmp`)
442
+ assertArtifactPathsSafeToWrite(root, [destination, temp])
443
+ const bytes = readFileSync(activePath)
444
+ atomicWrite(destination, bytes, 0o600, root)
445
+ unlinkSync(activePath)
446
+ return destination
447
+ }
448
+
449
+ /**
450
+ * Bind active local guide files to the authenticated (abstract,user) pair.
451
+ * Call only after fresh server authorization. On mismatch, old files are moved
452
+ * to ignored identity-specific recovery paths and never remain active.
453
+ */
454
+ export function activateGuideIdentity(projectRoot, abstractId, userId) {
455
+ const paths = guidePaths(projectRoot, abstractId, userId)
456
+ validateActiveGuidePaths(paths)
457
+ ensureGuideFilesIgnored(paths.projectRoot)
458
+ assertGuidePathsSafeToWrite(paths.projectRoot)
459
+ const active = activeIdentity(paths)
460
+ const same = active.value?.abstractId === paths.abstractId && active.value?.userId === paths.userId
461
+ if (same) return { status: 'active', privateSafeToLoad: true, recovered: [] }
462
+ if (!active.value && !active.malformed && !existsSync(paths.abstractGuide) && !existsSync(paths.privateGuide)) {
463
+ ensureDirectory(paths.projectRoot, dirname(paths.activeIdentity))
464
+ atomicWrite(paths.activeIdentity, `${JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId })}\n`, 0o600, paths.projectRoot)
465
+ return { status: 'active', privateSafeToLoad: true, recovered: [] }
466
+ }
467
+
468
+ let recoveryDirectory
469
+ if (active.value) {
470
+ const prior = guidePaths(paths.projectRoot, active.value.abstractId, active.value.userId)
471
+ recoveryDirectory = prior.recoveryDirectory
472
+ } else {
473
+ let opaque = 'unbound'
474
+ try { opaque = sha256(readFileSync(paths.activeIdentity)).slice(0, 16) } catch {
475
+ const bytes = FILES.map((file) => {
476
+ try { return readFileSync(checkedPath(paths.projectRoot, file)) } catch { return Buffer.alloc(0) }
477
+ })
478
+ opaque = sha256(Buffer.concat(bytes)).slice(0, 16)
479
+ }
480
+ recoveryDirectory = checkedPath(paths.projectRoot, '.amorphous-guides', 'recovery', paths.abstractId, `unknown-${opaque}`)
481
+ }
482
+ const recovered = []
483
+ for (const filename of FILES) {
484
+ const found = recoverActiveFile(paths.projectRoot, checkedPath(paths.projectRoot, filename), recoveryDirectory, filename)
485
+ if (found) recovered.push(found)
486
+ }
487
+ ensureDirectory(paths.projectRoot, dirname(paths.activeIdentity))
488
+ atomicWrite(paths.activeIdentity, `${JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId })}\n`, 0o600, paths.projectRoot)
489
+ return {
490
+ status: active.malformed || !active.value ? 'recovered-unknown-identity' : 'switched',
491
+ privateSafeToLoad: Boolean(active.value) && !active.malformed,
492
+ recovered,
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Materialize one already-authorized guide revision. Remote changes never
498
+ * replace a local edit: the remote body goes to an ignored conflict file.
499
+ */
500
+ export function materializeGuide({ projectRoot, abstractId, userId, kind, body, revision }) {
501
+ if (!['abstract', 'private'].includes(kind)) throw new Error('Guide kind must be abstract or private.')
502
+ if (typeof body !== 'string' || !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid guide document.')
503
+ if (Buffer.byteLength(body, 'utf8') > GUIDE_MAX_BYTES) throw new Error('Guide exceeds the 32 KiB UTF-8 limit.')
504
+ const paths = guidePaths(projectRoot, abstractId, userId)
505
+ const identity = activateGuideIdentity(paths.projectRoot, paths.abstractId, paths.userId)
506
+ if (!identity.privateSafeToLoad) return { status: 'identity-uncertain', recovered: identity.recovered }
507
+ const path = kind === 'abstract' ? paths.abstractGuide : paths.privateGuide
508
+ assertNoSymlinkChain(dirname(path))
509
+ const hash = sha256(body)
510
+ const state = readGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId)
511
+ const base = state?.guides?.[kind]
512
+ assertNoSymlink(path)
513
+ let local = null
514
+ try {
515
+ const info = lstatSync(path)
516
+ if (!info.isFile()) throw new Error(`Guide target must be a regular file: ${path}`)
517
+ local = readFileSync(path, 'utf8')
518
+ } catch (error) { if (error?.code !== 'ENOENT') throw error }
519
+
520
+ if (local === body) {
521
+ const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
522
+ writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
523
+ return { status: 'unchanged', path }
524
+ }
525
+ if (local !== null && base && sha256(local) !== base.hash && hash === base.hash) {
526
+ const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
527
+ writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
528
+ return { status: 'local-only', path, localHash: sha256(local) }
529
+ }
530
+ if (local !== null && (!base || sha256(local) !== base.hash)) {
531
+ ensureDirectory(paths.projectRoot, paths.conflictsDirectory)
532
+ const conflictPath = checkedPath(paths.projectRoot, relative(paths.projectRoot, paths.conflictsDirectory), `${kind}-remote-r${revision}-${randomUUID()}.md`)
533
+ atomicWrite(conflictPath, body, 0o600, paths.projectRoot)
534
+ return { status: 'conflict', path, conflictPath }
535
+ }
536
+
537
+ atomicWrite(path, body, 0o600, paths.projectRoot)
538
+ const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
539
+ writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
540
+ return { status: 'written', path }
541
+ }