loopat 0.1.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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,1832 @@
1
+ import { chmod, copyFile, mkdir, mkdtemp, readdir, readFile, rename, writeFile, stat, symlink, lstat, rm } from "node:fs/promises"
2
+ import { randomUUID } from "node:crypto"
3
+ import { execFile } from "node:child_process"
4
+ import { promisify } from "node:util"
5
+ import { existsSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { tmpdir } from "node:os"
8
+ import {
9
+ loopsDir,
10
+ loopDir,
11
+ loopWorkdir,
12
+ loopClaudeDir,
13
+ loopContextDir,
14
+ loopContextKnowledge,
15
+ loopContextNotes,
16
+ loopContextPersonal,
17
+ loopContextRepos,
18
+ loopMetaPath,
19
+ workspaceDir,
20
+ workspaceKnowledgeDir,
21
+ workspaceNotesDir,
22
+ workspaceReposDir,
23
+ workspaceRepoDir,
24
+ workspaceOriginsDir,
25
+ workspaceOriginPath,
26
+ personalDir,
27
+ uiNotesDir,
28
+ personalMemoryDir,
29
+ workspaceMemoryDir,
30
+ hostDeployKeyPath,
31
+ personalGitCryptKeyPath,
32
+ loopHistoryPath,
33
+ loopChatHistoryPath,
34
+ loopKindClaudePath,
35
+ } from "./paths"
36
+ import type { RepoSpec } from "./config"
37
+ import { existsSync as existsSyncBase } from "node:fs"
38
+ import { loadConfig } from "./config"
39
+ import { ensurePersonalKeypair } from "./personal-keys"
40
+ import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
41
+ import { getProvider } from "./git-host"
42
+ import { loadExtensionProviders } from "./providers" // also registers built-in providers
43
+
44
+ const execFileP = promisify(execFile)
45
+
46
+ export type LoopMeta = {
47
+ id: string
48
+ title: string
49
+ createdAt: string
50
+ createdBy: string
51
+ /**
52
+ * Active driver. New loops set this to `createdBy` on creation. Legacy
53
+ * loops created before drivers existed may omit it; callers should use
54
+ * `effectiveDriver()` rather than reading this field directly.
55
+ *
56
+ * The driver is the user whose personal config (apiKey, vault, env) the
57
+ * sandbox runs under, and the only user permitted to write (send messages,
58
+ * change provider, write terminal, etc.). Non-driver users are read-only —
59
+ * same set of writes blocked by `archived`. See request-for-drive flow.
60
+ */
61
+ driver?: string
62
+ /**
63
+ * Chronological log of driver assignments. First entry is creation time
64
+ * (driver = createdBy). Each subsequent entry is a successful handoff via
65
+ * POST /api/loops/:id/drive. Used by the chat UI to splice "driving by X
66
+ * since <ts>" markers into the message timeline. Legacy loops may omit
67
+ * this; on the next handoff a fresh history starts from there.
68
+ */
69
+ driverHistory?: Array<{ driver: string; since: string }>
70
+ /**
71
+ * RFD ("Request For Drive") state. When set, the current driver has
72
+ * released control: the sandbox is torn down, and any authenticated user
73
+ * may take over via POST /api/loops/:id/drive. Cleared when someone drives.
74
+ */
75
+ rfdRequestedAt?: string
76
+ rfdRequestedBy?: string
77
+ /**
78
+ * One-shot flag written by POST /api/loops/:id/drive, consumed by the next
79
+ * sendUserText. While set, the next user message is prefixed with a
80
+ * handoff preamble so the model knows the user it's talking to has just
81
+ * changed. Cleared atomically when consumed.
82
+ */
83
+ pendingDriverNote?: { from: string; to: string; at: string }
84
+ repo?: string
85
+ branch?: string
86
+ config?: {
87
+ default_model?: string
88
+ default_model_source?: "personal" | "workspace"
89
+ default_model_id?: string
90
+ permission_mode?: string
91
+ /**
92
+ * Active profiles for this loop (post-2026-05 composition model).
93
+ * Profiles live in `<LOOPAT_HOME>/context/profiles/<name>/`; each has a
94
+ * profile.json (lists plugin specs) + sibling CLAUDE.md + optional
95
+ * knowledge/. On spawn, loopat orchestrates `claude plugin install` for
96
+ * the union of plugins, concats CLAUDE.mds, mounts knowledge.
97
+ *
98
+ * Order matters: CLAUDE.md fragments concat in declared order (later
99
+ * shadows earlier). "base" profile is always implicit if present, even
100
+ * when this list is empty. Personal CLAUDE.md appends last.
101
+ *
102
+ * Empty / undefined = no profile-driven plugins, base CLAUDE.md only
103
+ * (if it exists), personal CLAUDE.md only. CC still runs.
104
+ *
105
+ * See docs/composition.md.
106
+ */
107
+ profiles?: string[]
108
+ /**
109
+ * Vault selected for this loop. The named vault under
110
+ * `personal/<user>/.loopat/vaults/<vault>/` provides this loop's
111
+ * credentials at runtime. Default: "default". The act of choosing here
112
+ * is the security boundary — other vaults are not exposed inside the
113
+ * sandbox. Set to null only by very old loops created before vaults
114
+ * existed; bwrap treats absent/null as "default" for backward compat.
115
+ */
116
+ vault?: string
117
+ /**
118
+ * If true, /loopat/context/knowledge/ is bound rw instead of ro. Set
119
+ * for loops that exist to distill notes into knowledge.
120
+ */
121
+ knowledge_rw?: boolean
122
+ /**
123
+ * Admin-only flag: bind the entire LOOPAT_HOME/loops/ tree read-only
124
+ * at /loopat/loops/ so this loop can read every other loop's chat
125
+ * history, workdir, meta, etc. — for cross-loop distill. Granted only
126
+ * to admins at create time; cannot be toggled later.
127
+ *
128
+ * Privacy note: this exposes other users' chats and workdirs to the
129
+ * driver of this loop. Don't ship a UI that lets non-admins flip it.
130
+ */
131
+ mount_all_loops?: boolean
132
+ /** Session-scoped goal set via /goal. Displayed in UI and injected into the system prompt. */
133
+ goal?: string
134
+ goalSetAt?: string
135
+ goalStatus?: "active" | "completed"
136
+ }
137
+ /**
138
+ * Archive = "hide + read-only". Hidden from default list, all writes
139
+ * (sendUserText / clear / setProvider / writeTerm / answerQuestions /
140
+ * vault writes) reject. Reads stay open (attach, history, files, term
141
+ * view). Lossless — `unarchive` flips back. See docs/design notes.
142
+ */
143
+ archived?: boolean
144
+ archivedAt?: string
145
+ /**
146
+ * Free-form key/value metadata attached by the caller of the v1 Loop API.
147
+ * Not interpreted by loopat; not exposed to the sandbox. Used by external
148
+ * integrations (e.g. a bot framework storing "slack_thread: C123:1234").
149
+ * Capped at 16 KB JSON-serialized.
150
+ */
151
+ metadata?: Record<string, unknown>
152
+ /**
153
+ * If true, this loop's chat (and only the chat) is readable by anonymous
154
+ * visitors at `/share/:id`. Everything else (workspace, files, kanban, ...)
155
+ * still requires auth. Only the loop's `createdBy` may toggle it.
156
+ */
157
+ public?: boolean
158
+ publicAt?: string
159
+ /**
160
+ * Workspace serve config. When shareEnabled, the loop's workdir is accessible
161
+ * via one of three modes:
162
+ *
163
+ * - "static" — serve container streams workdir files via subdomain
164
+ * - "port" — serve container HTTP-proxies to sharePort via subdomain
165
+ * - "direct" — port-proxy container TCP/UDP-relays a fixed external
166
+ * port (shareExternalPort) to sharePort
167
+ * - "ephemeral" — the loop container itself publishes sharePort via
168
+ * `-p :<sharePort>`, kernel-assigned host port that
169
+ * changes on every container restart. No port-proxy.
170
+ * Read the current host port via `podman port`.
171
+ */
172
+ shareEnabled?: boolean
173
+ shareMode?: "static" | "port" | "ephemeral"
174
+ shareAlias?: string
175
+ sharePort?: number
176
+ /** External port for direct TCP/UDP access (see port-proxy). */
177
+ shareExternalPort?: number
178
+ /** Protocol for shareExternalPort: "tcp" (default), "udp", or "static". */
179
+ shareProtocol?: "tcp" | "udp" | "static"
180
+ /**
181
+ * Set when the loop was spawned from a chat conversation. The snapshot of
182
+ * the chat history is at loops/<id>/context/chat/<convId>.jsonl (mounted as
183
+ * /loopat/context/chat/<convId>.jsonl inside the sandbox).
184
+ */
185
+ seededFrom?: {
186
+ kind: "chat"
187
+ convId: string
188
+ messageCount: number
189
+ snapshotAt: string
190
+ }
191
+ /**
192
+ * Last metadata received from the external runtime gateway. Written by
193
+ * `recordExternalMeta` on each turn so the UI / admin can see which
194
+ * external platform and user this loop serves. Only present on loops
195
+ * created via the gateway SSE API.
196
+ */
197
+ lastExternalMeta?: {
198
+ source: string | null
199
+ userId: string | null
200
+ metadata: Record<string, unknown> | null
201
+ traceId: string | null
202
+ at: string
203
+ }
204
+ }
205
+
206
+ const PERSONAL_MEMORY_INDEX_STUB = `# Personal memory index
207
+
208
+ Each line points at a memory file in this directory. Maintained by Claude.
209
+
210
+ `
211
+
212
+ const TEAM_MEMORY_INDEX_STUB = `# Team memory index
213
+
214
+ Cross-loop, cross-user memory shared via the notes git repo. One line per entry.
215
+ Promote here only when the insight is workspace-wide (a convention, an
216
+ operational fact, a non-obvious gotcha). Routine observations belong in
217
+ \`/loopat/context/personal/memory/\` instead.
218
+
219
+ `
220
+
221
+ /**
222
+ * Who is currently driving this loop — `meta.driver` if set, else the
223
+ * creator. Use this everywhere "whose credentials/permissions" matters.
224
+ * Reserve direct `meta.createdBy` reads for "who owns this loop forever"
225
+ * (archive, public toggle).
226
+ */
227
+ export function effectiveDriver(meta: { createdBy: string; driver?: string }): string {
228
+ return meta.driver ?? meta.createdBy
229
+ }
230
+
231
+ export function isDriver(meta: { createdBy: string; driver?: string }, userId: string): boolean {
232
+ return effectiveDriver(meta) === userId
233
+ }
234
+
235
+ /**
236
+ * Derive the ephemeral `-p` set to pass into the loop's container at create
237
+ * time. Returns an empty list unless the loop is in "ephemeral" share mode
238
+ * with a valid internal port. Static mode and the legacy "port"/"direct"
239
+ * modes don't touch the loop container's own port mappings (they go via
240
+ * the serve / port-proxy containers instead).
241
+ */
242
+ export function loopEphemeralPorts(
243
+ meta: Pick<LoopMeta, "shareEnabled" | "shareMode" | "sharePort" | "shareProtocol">,
244
+ ): { internalPort: number; protocol?: "tcp" | "udp" }[] {
245
+ if (!meta.shareEnabled || meta.shareMode !== "ephemeral" || !meta.sharePort) return []
246
+ const proto = meta.shareProtocol === "udp" ? "udp" : "tcp"
247
+ return [{ internalPort: meta.sharePort, protocol: proto }]
248
+ }
249
+
250
+ async function gitInitIfMissing(dir: string) {
251
+ if (existsSyncBase(join(dir, ".git"))) return
252
+ try {
253
+ await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"])
254
+ } catch (e: any) {
255
+ console.warn(`[loopat] git init failed for ${dir}: ${e?.message ?? e}`)
256
+ }
257
+ }
258
+
259
+ async function isEmptyOrMissing(dir: string): Promise<boolean> {
260
+ if (!existsSyncBase(dir)) return true
261
+ try {
262
+ const names = await readdir(dir)
263
+ return names.length === 0
264
+ } catch {
265
+ return true
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Materialize a context repo (knowledge / notes) with an `origin` to pull/push.
271
+ * Remote backend: clone the configured git url. Local backend (no url): loopat
272
+ * hosts the remote itself — a bare repo at origins/<name>.git becomes `origin`
273
+ * (docs/context-flow.md "solo"). Either way the working dir ends up a git repo
274
+ * with `origin` set, so the symmetric pull/push model just works.
275
+ */
276
+ async function ensureContextRepo(dir: string, name: string, url?: string): Promise<void> {
277
+ if (url && (await isEmptyOrMissing(dir))) {
278
+ try {
279
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
280
+ await mkdir(join(dir, ".."), { recursive: true })
281
+ await execFileP("git", ["clone", "--", url, dir])
282
+ console.log(`[loopat] cloned ${url} → ${dir}`)
283
+ return
284
+ } catch (e: any) {
285
+ console.warn(`[loopat] clone failed (${url}): ${e?.stderr ?? e?.message ?? e} — falling back to local origin`)
286
+ }
287
+ }
288
+ // Local backend: loopat-hosted bare origin.
289
+ const bare = workspaceOriginPath(name)
290
+ if (!existsSyncBase(join(bare, "HEAD"))) {
291
+ await mkdir(workspaceOriginsDir(), { recursive: true })
292
+ try {
293
+ await execFileP("git", ["init", "--bare", "-b", "main", bare])
294
+ } catch (e: any) {
295
+ console.warn(`[loopat] bare init failed (${bare}): ${e?.message ?? e}`)
296
+ }
297
+ }
298
+ if (await isEmptyOrMissing(dir)) {
299
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
300
+ await mkdir(join(dir, ".."), { recursive: true })
301
+ try {
302
+ await execFileP("git", ["clone", "--", bare, dir])
303
+ } catch {
304
+ await mkdir(dir, { recursive: true })
305
+ await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {})
306
+ await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
307
+ }
308
+ } else if (existsSyncBase(join(dir, ".git"))) {
309
+ const hasOrigin = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
310
+ if (!hasOrigin) await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
311
+ } else {
312
+ // non-empty dir that isn't a git repo yet (e.g. a freshly-scaffolded
313
+ // personal/) → init in place and point it at the local bare origin.
314
+ await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {})
315
+ await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Repos are clone-on-demand — they can be large, so we don't pre-clone the
321
+ * whole set. Instead write a manifest (REPOS.md) listing the full roster, and
322
+ * clone a repo only when it's actually needed. Per docs/context-flow.md the AI
323
+ * can also clone any listed repo by hand into context/repos/<name>.
324
+ */
325
+ async function writeReposManifest(specs: RepoSpec[]) {
326
+ await mkdir(workspaceReposDir(), { recursive: true })
327
+ const body = [
328
+ "# repos — clone on demand",
329
+ "",
330
+ "Full roster below. Only already-cloned repos exist as subdirectories;",
331
+ "clone any other on demand: `git clone <git> /loopat/context/repos/<name>`.",
332
+ "",
333
+ ...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
334
+ "",
335
+ ].join("\n")
336
+ await writeFile(join(workspaceReposDir(), "REPOS.md"), body)
337
+ }
338
+
339
+ /**
340
+ * Clone a single registered repo if it isn't present yet. Returns whether the
341
+ * repo dir exists afterwards. Used by loop creation and any on-demand path.
342
+ */
343
+ async function ensureRepoCloned(name: string): Promise<boolean> {
344
+ const dir = workspaceRepoDir(name)
345
+ if (existsSyncBase(dir)) return true
346
+ const cfg = await loadConfig()
347
+ const spec = cfg.repos?.find((r) => r.name === name)
348
+ if (!spec?.git) return false
349
+ try {
350
+ await mkdir(workspaceReposDir(), { recursive: true })
351
+ await execFileP("git", ["clone", "--", spec.git, dir])
352
+ console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
353
+ return true
354
+ } catch (e: any) {
355
+ console.warn(`[loopat] repo clone failed (${spec.git}): ${e?.stderr ?? e?.message ?? e}`)
356
+ return false
357
+ }
358
+ }
359
+
360
+ export async function ensureWorkspaceDirs() {
361
+ await mkdir(workspaceDir(), { recursive: true })
362
+ await mkdir(loopsDir(), { recursive: true })
363
+ await mkdir(workspaceReposDir(), { recursive: true })
364
+
365
+ // knowledge / notes / repos: clone from config'd remote if present
366
+ const cfg = await loadConfig()
367
+ await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
368
+ await ensureContextRepo(workspaceNotesDir(), "notes", cfg.notes?.git || undefined)
369
+ await writeReposManifest(cfg.repos ?? [])
370
+
371
+ // workspace memory dir + stub
372
+ const tm = workspaceMemoryDir()
373
+ await mkdir(tm, { recursive: true })
374
+ const tmIdx = `${tm}/MEMORY.md`
375
+ if (!existsSyncBase(tmIdx)) await writeFile(tmIdx, TEAM_MEMORY_INDEX_STUB)
376
+
377
+ // knowledge / notes are already git repos with `origin` (ensureContextRepo).
378
+
379
+ }
380
+
381
+ /**
382
+ * Provision a freshly-registered user's personal/ tree. NEVER clones the
383
+ * user's remote repo here — the server has no credentials for private repos
384
+ * at register time. We:
385
+ * 1. mkdir + `git init` an empty personal/<user>/
386
+ * 2. seed `memory/MEMORY.md` so SDK auto-recall sees something
387
+ * 3. generate a loopat-managed ed25519 keypair under
388
+ * `host-secrets/<user>/deploy-key` (deploy-key flow, host-only)
389
+ *
390
+ * If `personalRepo` was given at register, the user goes through a separate
391
+ * confirm step (see `importPersonalFromRepo`) AFTER they paste the public key
392
+ * as a deploy key on the remote.
393
+ *
394
+ * Returns the public key so the UI can show it.
395
+ */
396
+ export async function provisionUserPersonal(userId: string): Promise<{ publicKey: string | null }> {
397
+ const dir = personalDir(userId)
398
+ await mkdir(dir, { recursive: true })
399
+
400
+ const pm = personalMemoryDir(userId)
401
+ await mkdir(pm, { recursive: true })
402
+ const pmIdx = `${pm}/MEMORY.md`
403
+ if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
404
+
405
+ // personal gets a loopat-hosted bare origin too (local backend), so its
406
+ // promote is `push origin` like every other context repo. A later import of
407
+ // the user's own remote repo (importPersonalFromRepo) replaces this origin.
408
+ await ensureContextRepo(dir, `personal-${userId}`, undefined)
409
+
410
+ const { publicKey } = await ensurePersonalKeypair(userId)
411
+ return { publicKey }
412
+ }
413
+
414
+ /**
415
+ * Provider-agnostic personal onboarding (docs/identity.md integration contract).
416
+ * Uses a host-side credential (token, never enters a sandbox) via the selected
417
+ * GitHostProvider to create the personal repo + register the deploy key, then
418
+ * reuses importPersonalFromRepo to clone + handle git-crypt (empty repo →
419
+ * auto-init and return the generated key; existing → needsCryptKey).
420
+ *
421
+ * The token only *sets things up*; runtime git uses the deploy key / vault.
422
+ * `provider` selects the git platform (default "github"); add platforms by
423
+ * implementing GitHostProvider (see git-host.ts / providers.ts).
424
+ */
425
+ export async function setupPersonalViaProvider(opts: {
426
+ userId: string
427
+ provider?: string
428
+ token: string
429
+ baseUrl?: string
430
+ repoName: string
431
+ cryptKey?: string
432
+ }): Promise<
433
+ | { ok: true; repo: string; repoUrl: string; created: boolean; autoInitialized?: boolean; cryptKey?: string }
434
+ | { ok: false; error: string; needsCryptKey?: boolean }
435
+ > {
436
+ await loadExtensionProviders() // ensure external (internal-platform) providers are registered
437
+ const provider = getProvider(opts.provider ?? "github")
438
+ if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` }
439
+ const cred = { token: opts.token, baseUrl: opts.baseUrl }
440
+
441
+ let login: string
442
+ let email: string | undefined
443
+ try {
444
+ const auth = await provider.authenticate(cred)
445
+ login = auth.login
446
+ email = auth.email
447
+ } catch (e: any) {
448
+ return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` }
449
+ }
450
+
451
+ let repo: { url: string; created: boolean }
452
+ try {
453
+ repo = await provider.ensureRepo(cred, opts.repoName, { private: true })
454
+ } catch (e: any) {
455
+ return { ok: false, error: `ensure repo failed: ${e?.message ?? e}` }
456
+ }
457
+
458
+ // Set up git auth per the provider's mode.
459
+ let cloneUrl = repo.url
460
+ if (provider.gitAuthMode === "ssh-deploy-key") {
461
+ // GitHub-style: register a loopat-generated deploy key; git clones via ssh.
462
+ const { publicKey } = await ensurePersonalKeypair(opts.userId)
463
+ if (publicKey && provider.registerDeployKey) {
464
+ try {
465
+ await provider.registerDeployKey(cred, { owner: login, name: opts.repoName }, `loopat:${opts.userId}`, publicKey, false)
466
+ } catch (e: any) {
467
+ return { ok: false, error: `register deploy key failed: ${e?.message ?? e}` }
468
+ }
469
+ }
470
+ } else {
471
+ // https-token git: https://<login>:<token>@host/path — GitLab/Code use the
472
+ // username + private_token as basic auth (GitHub PAT works the same way).
473
+ // Normalize http→https. (MVP: the token lands in the worktree's .git/config —
474
+ // fine for a private, user-owned personal repo; a credential-helper pass can
475
+ // harden it later.)
476
+ cloneUrl = repo.url.replace(
477
+ /^https?:\/\//,
478
+ `https://${encodeURIComponent(login)}:${encodeURIComponent(opts.token)}@`,
479
+ )
480
+ }
481
+
482
+ // Clone + git-crypt via the existing import path (commit author from the
483
+ // platform identity — some hosts reject non-corporate emails).
484
+ // Internal-setup hook (optional): the provider may seed default files into
485
+ // the fresh repo (provider configs, ssh keys, …). Only fires on auto-init.
486
+ const seed = provider.seedDefaults
487
+ ? (repoDir: string) =>
488
+ provider.seedDefaults!({
489
+ repoDir,
490
+ vaultDir: join(repoDir, ".loopat", "vaults", "default"),
491
+ userId: opts.userId,
492
+ login,
493
+ })
494
+ : undefined
495
+ const imp = await importPersonalFromRepo(opts.userId, cloneUrl, opts.cryptKey, { name: login, email }, seed)
496
+ if (!imp.ok) return { ok: false, error: imp.error, needsCryptKey: imp.needsCryptKey }
497
+ return {
498
+ ok: true,
499
+ repo: `${login}/${opts.repoName}`,
500
+ repoUrl: repo.url,
501
+ created: repo.created,
502
+ autoInitialized: imp.autoInitialized,
503
+ cryptKey: imp.cryptKey,
504
+ }
505
+ }
506
+
507
+ /** Back-compat thin wrapper — GitHub is just the default provider. */
508
+ export async function setupPersonalViaGithub(opts: {
509
+ userId: string
510
+ token: string
511
+ repoName: string
512
+ baseUrl?: string
513
+ cryptKey?: string
514
+ }) {
515
+ return setupPersonalViaProvider({ ...opts, provider: "github" })
516
+ }
517
+
518
+ /** List the user's repos via a provider (onboarding picker), "personal"-named
519
+ * first. Empty when the provider can't list or the call fails. */
520
+ export async function listPersonalReposViaProvider(opts: {
521
+ provider?: string
522
+ token: string
523
+ baseUrl?: string
524
+ }): Promise<{ name: string; path: string }[]> {
525
+ await loadExtensionProviders()
526
+ const provider = getProvider(opts.provider ?? "github")
527
+ if (!provider?.listRepos) return []
528
+ let repos: { name: string; path: string }[]
529
+ try {
530
+ repos = await provider.listRepos({ token: opts.token, baseUrl: opts.baseUrl })
531
+ } catch {
532
+ return []
533
+ }
534
+ return repos.sort((a, b) => (b.name.includes("personal") ? 1 : 0) - (a.name.includes("personal") ? 1 : 0))
535
+ }
536
+
537
+ /** Validate a token by authenticating it against the provider. The onboarding
538
+ * picker calls this to fail fast on a bad token instead of silently showing
539
+ * an empty repo list. */
540
+ export async function authenticateViaProvider(opts: {
541
+ provider?: string
542
+ token: string
543
+ baseUrl?: string
544
+ }): Promise<{ ok: true; login: string } | { ok: false; error: string }> {
545
+ await loadExtensionProviders()
546
+ const provider = getProvider(opts.provider ?? "github")
547
+ if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` }
548
+ try {
549
+ const auth = await provider.authenticate({ token: opts.token, baseUrl: opts.baseUrl })
550
+ return { ok: true, login: auth.login }
551
+ } catch (e: any) {
552
+ return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` }
553
+ }
554
+ }
555
+
556
+ /** The provider's optional token-help hint (URL/text), for the onboarding UI. */
557
+ export async function providerTokenHelp(providerId?: string): Promise<string | null> {
558
+ await loadExtensionProviders()
559
+ return getProvider(providerId ?? "github")?.tokenHelp ?? null
560
+ }
561
+
562
+ /**
563
+ * Detect whether `personal/<user>/` is "fresh" — i.e. only has the
564
+ * scaffolding we put there (`.git`, `memory/`). If yes, it's safe to wipe +
565
+ * clone over the top. Anything else means we refuse to overwrite.
566
+ *
567
+ * Note: host-secrets/<user>/ lives OUTSIDE personal/<user>/ so it's not
568
+ * part of this check and survives import without preservation logic.
569
+ */
570
+ export async function isPersonalFresh(userId: string): Promise<boolean> {
571
+ const dir = personalDir(userId)
572
+ try {
573
+ const entries = await readdir(dir)
574
+ const SCAFFOLD = new Set([".git", "memory"])
575
+ return entries.every((e) => SCAFFOLD.has(e))
576
+ } catch {
577
+ return true
578
+ }
579
+ }
580
+
581
+ /**
582
+ * One-shot clone using the user's loopat-managed deploy key. Replaces the
583
+ * fresh-scaffolded `personal/<user>/` with the cloned repo.
584
+ *
585
+ * Two paths:
586
+ *
587
+ * 1. Default (auto-init). User provides a *clean* repo URL (no git-crypt
588
+ * config and no tracked `.loopat/vaults/**`). Server clones, runs
589
+ * `git-crypt init`, writes `.gitattributes` + `.gitignore`, commits the
590
+ * scaffold, and pushes. The newly-generated symmetric key is saved under
591
+ * `host-secrets/<user>/git-crypt.key` AND returned to the caller exactly
592
+ * once so the UI can show it for backup.
593
+ *
594
+ * 2. Recovery (BYOK). User pastes a base64-encoded git-crypt key in
595
+ * `cryptKey`. Repo must already be a git-crypt'd loopat repo (typical
596
+ * case: same user, new host). Server runs `git-crypt unlock`, stores the
597
+ * key under host-secrets/, swaps personal/ in.
598
+ *
599
+ * Anything in between (partially set-up repo, leftover plaintext secrets,
600
+ * git-crypt configured but no key supplied, etc.) is refused with a precise
601
+ * error so the user knows what to fix.
602
+ *
603
+ * Returns { ok: false, error } on any failure; on failure personal/<user>/
604
+ * is left untouched (we clone into a temp dir first).
605
+ */
606
+ export async function importPersonalFromRepo(
607
+ userId: string,
608
+ repoUrl: string,
609
+ cryptKey?: string,
610
+ author?: { name?: string; email?: string },
611
+ seed?: (repoDir: string) => Promise<void>,
612
+ ): Promise<
613
+ | { ok: true; autoInitialized?: boolean; cryptKey?: string }
614
+ | {
615
+ ok: false
616
+ error: string
617
+ needsCryptKey?: boolean
618
+ notClean?: boolean
619
+ secretsExposed?: boolean
620
+ exposedFiles?: string[]
621
+ }
622
+ > {
623
+ if (!repoUrl?.trim()) return { ok: false, error: "repoUrl required" }
624
+
625
+ // Refuse if the user has already populated personal/. We don't want to nuke
626
+ // their work. They can `rm -rf` manually and retry if that's really intended.
627
+ if (!(await isPersonalFresh(userId))) {
628
+ return { ok: false, error: "personal/ is not empty — refusing to overwrite" }
629
+ }
630
+
631
+ // https-token urls carry their own auth (https://user:token@…) and need no
632
+ // ssh deploy key; ssh urls require the loopat-managed deploy key.
633
+ const isHttps = /^https?:\/\//.test(repoUrl)
634
+ const priv = hostDeployKeyPath(userId)
635
+ if (!isHttps && !existsSyncBase(priv)) {
636
+ return { ok: false, error: "deploy keypair missing — re-register" }
637
+ }
638
+
639
+ // Clone into a tmp dir. ssh uses the deploy key (StrictHostKeyChecking=
640
+ // accept-new, no pre-populated known_hosts on first run); https auths via url.
641
+ const tmp = await mkdtemp(join(tmpdir(), `loopat-import-${userId}-`))
642
+ const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) }
643
+ try {
644
+ await execFileP("git", ["clone", "--", repoUrl, tmp], { env: cloneEnv })
645
+ } catch (e: any) {
646
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
647
+ const msg = (e?.stderr || e?.message || String(e)).toString().trim().split("\n").slice(-3).join(" ")
648
+ return { ok: false, error: `clone failed: ${msg}` }
649
+ }
650
+
651
+ // Exposure check (always, regardless of path): refuse to adopt a repo whose
652
+ // .loopat/vaults/** are plaintext in git. Even with BYOK this is bad —
653
+ // if any single secret blob is plaintext, those secrets are already burned.
654
+ const exposed = await detectExposedSecrets(tmp)
655
+ if (exposed.length > 0) {
656
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
657
+ return {
658
+ ok: false,
659
+ error: "secrets are exposed (plaintext) in this repo's git history",
660
+ secretsExposed: true,
661
+ exposedFiles: exposed.slice(0, 20),
662
+ }
663
+ }
664
+
665
+ const hasGitCrypt = await detectGitCryptEnabled(tmp)
666
+ const trackedSecrets = await listTrackedSecretFiles(tmp)
667
+
668
+ if (cryptKey?.trim()) {
669
+ // ── BYOK / recovery path ──
670
+ if (!hasGitCrypt) {
671
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
672
+ return {
673
+ ok: false,
674
+ error:
675
+ "you provided a crypt key but this repo has no git-crypt config — leave the key field empty to let loopat initialize the repo, or point at the right repo",
676
+ }
677
+ }
678
+ const unlockResult = await unlockWithCryptKey(tmp, userId, cryptKey)
679
+ if (!unlockResult.ok) {
680
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
681
+ return { ok: false, error: unlockResult.error }
682
+ }
683
+ return await swapPersonalDir(userId, tmp)
684
+ }
685
+
686
+ // ── Default / auto-init path ──
687
+ // Require a strictly clean repo: no git-crypt config, no tracked secrets.
688
+ if (hasGitCrypt) {
689
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
690
+ return {
691
+ ok: false,
692
+ notClean: true,
693
+ error:
694
+ "this repo already has git-crypt configured — either point at a fresh empty repo, or paste your existing crypt key under Recovery to import it",
695
+ }
696
+ }
697
+ if (trackedSecrets.length > 0) {
698
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
699
+ return {
700
+ ok: false,
701
+ notClean: true,
702
+ error: `\`.loopat/vaults/\` in this repo isn't empty (${trackedSecrets.length} file(s) tracked) — use a fresh repo`,
703
+ }
704
+ }
705
+
706
+ const init = await autoInitGitCrypt(tmp, userId, author, seed)
707
+ if (!init.ok) {
708
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
709
+ return { ok: false, error: init.error }
710
+ }
711
+
712
+ const swap = await swapPersonalDir(userId, tmp)
713
+ if (!swap.ok) return swap
714
+ return { ok: true, autoInitialized: true, cryptKey: init.cryptKey }
715
+ }
716
+
717
+ function sshCommandForUser(userId: string): string {
718
+ const priv = hostDeployKeyPath(userId)
719
+ return `ssh -i ${priv} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
720
+ }
721
+
722
+ async function swapPersonalDir(
723
+ userId: string,
724
+ tmp: string,
725
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
726
+ const dir = personalDir(userId)
727
+ try {
728
+ await mkdir(join(dir, ".."), { recursive: true })
729
+ await rm(dir, { recursive: true, force: true })
730
+ await rename(tmp, dir)
731
+ const pm = personalMemoryDir(userId)
732
+ await mkdir(pm, { recursive: true })
733
+ const pmIdx = `${pm}/MEMORY.md`
734
+ if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
735
+ return { ok: true }
736
+ } catch (e: any) {
737
+ return { ok: false, error: `swap failed: ${e?.message ?? e}` }
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Server-side bootstrap for a clean personal repo: git-crypt init, write the
743
+ * scaffold (`.gitattributes`, `.gitignore`, `.loopat/vaults/default/.gitkeep`),
744
+ * commit, push, and stash the freshly-generated symmetric key under
745
+ * host-secrets/<user>/. Returns the key base64-encoded so the UI can show it
746
+ * to the user exactly once for backup.
747
+ *
748
+ * On any failure (clone tampered, push permission missing, git-crypt missing)
749
+ * the saved host-secrets key is rolled back so a retry starts from scratch.
750
+ */
751
+ async function autoInitGitCrypt(
752
+ repoDir: string,
753
+ userId: string,
754
+ author?: { name?: string; email?: string },
755
+ seed?: (repoDir: string) => Promise<void>,
756
+ ): Promise<{ ok: true; cryptKey: string } | { ok: false; error: string }> {
757
+ // git-crypt must be on the host; check early with a useful error
758
+ try {
759
+ await execFileP("git-crypt", ["--version"])
760
+ } catch {
761
+ return {
762
+ ok: false,
763
+ error: "git-crypt not installed on host (sudo apt install git-crypt / brew install git-crypt)",
764
+ }
765
+ }
766
+
767
+ // Local-only commit author so this doesn't depend on global git config
768
+ try {
769
+ await execFileP("git", ["-C", repoDir, "config", "user.email", author?.email ?? "loopat@local"])
770
+ await execFileP("git", ["-C", repoDir, "config", "user.name", author?.name ?? "loopat"])
771
+ } catch (e: any) {
772
+ return { ok: false, error: `git config failed: ${e?.message ?? e}` }
773
+ }
774
+
775
+ try {
776
+ await execFileP("git-crypt", ["init"], { cwd: repoDir })
777
+ } catch (e: any) {
778
+ const stderr = (e?.stderr ?? "").toString().trim()
779
+ return { ok: false, error: `git-crypt init failed: ${stderr || e?.message || e}` }
780
+ }
781
+
782
+ // Merge .gitattributes (preserve any existing lines, e.g. LFS / line endings).
783
+ await appendLineIfMissing(
784
+ join(repoDir, ".gitattributes"),
785
+ ".loopat/vaults/** filter=git-crypt diff=git-crypt",
786
+ (existing, line) => existing.includes(line),
787
+ )
788
+
789
+ // Merge .gitignore so host-only state under .loopat/host/ never gets pushed
790
+ await appendLineIfMissing(
791
+ join(repoDir, ".gitignore"),
792
+ "/.loopat/host/",
793
+ (existing, line) =>
794
+ existing.split("\n").some((l) => l.trim() === line || l.trim() === ".loopat/host/"),
795
+ )
796
+
797
+ // Scaffold vaults/default/ so future writes land in a tracked directory.
798
+ // New imports start in the new layout; legacy `secrets/` is only consulted
799
+ // for users who imported before vaults existed.
800
+ await mkdir(join(repoDir, ".loopat/vaults/default"), { recursive: true })
801
+ await writeFile(join(repoDir, ".loopat/vaults/default/.gitkeep"), "")
802
+
803
+ // Ship the memory index in the scaffold commit so cloning onto a second
804
+ // host doesn't depend on swapPersonalDir's late top-up.
805
+ await mkdir(join(repoDir, "memory"), { recursive: true })
806
+ if (!existsSyncBase(join(repoDir, "memory/MEMORY.md"))) {
807
+ await writeFile(join(repoDir, "memory/MEMORY.md"), PERSONAL_MEMORY_INDEX_STUB)
808
+ }
809
+
810
+ // Internal-setup hook: let the provider seed default files (provider configs,
811
+ // ssh keys, …) into the working tree now. git-crypt is initialized, so
812
+ // anything written under .loopat/vaults/** is encrypted, and the scaffold
813
+ // commit below picks it up via `git add .loopat`. Non-fatal.
814
+ if (seed) {
815
+ try {
816
+ await seed(repoDir)
817
+ } catch (e: any) {
818
+ console.warn(`[loopat] seedDefaults hook failed: ${e?.message ?? e}`)
819
+ }
820
+ }
821
+
822
+ // Export the key BEFORE pushing so a push failure rolls back to a state
823
+ // that knows whether we had the key at all
824
+ let cryptKeyB64: string
825
+ let keyBuf: Buffer
826
+ try {
827
+ const exportPath = join(repoDir, ".git", "git-crypt-export.key")
828
+ await execFileP("git-crypt", ["export-key", exportPath], { cwd: repoDir })
829
+ keyBuf = await readFile(exportPath)
830
+ cryptKeyB64 = keyBuf.toString("base64")
831
+ await rm(exportPath, { force: true })
832
+ } catch (e: any) {
833
+ const stderr = (e?.stderr ?? "").toString().trim()
834
+ return { ok: false, error: `git-crypt export-key failed: ${stderr || e?.message || e}` }
835
+ }
836
+
837
+ // Persist to host-secrets BEFORE push so loop start-up code can find it
838
+ // even if push partially succeeded. We undo on push failure below.
839
+ const { saveGitCryptKey } = await import("./git-crypt-key")
840
+ try {
841
+ await saveGitCryptKey(userId, keyBuf)
842
+ } catch (e: any) {
843
+ return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` }
844
+ }
845
+
846
+ // Stage + commit
847
+ try {
848
+ await execFileP("git", [
849
+ "-C",
850
+ repoDir,
851
+ "add",
852
+ ".gitattributes",
853
+ ".gitignore",
854
+ ".loopat",
855
+ "memory",
856
+ ])
857
+ await execFileP("git", [
858
+ "-C",
859
+ repoDir,
860
+ "commit",
861
+ "-m",
862
+ "loopat: initialize personal vault (git-crypt enabled)",
863
+ ])
864
+ } catch (e: any) {
865
+ await rollbackSavedKey(userId)
866
+ const stderr = (e?.stderr ?? "").toString().trim()
867
+ return { ok: false, error: `commit failed: ${stderr || e?.message || e}` }
868
+ }
869
+
870
+ // Determine target branch: prefer existing local HEAD (carries remote's
871
+ // default); fall back to "main" for the empty-repo case where there's no
872
+ // symbolic ref to follow.
873
+ let branch = "main"
874
+ try {
875
+ const { stdout } = await execFileP("git", ["-C", repoDir, "symbolic-ref", "--short", "HEAD"])
876
+ const v = stdout.trim()
877
+ if (v) branch = v
878
+ } catch {}
879
+
880
+ try {
881
+ await execFileP("git", ["-C", repoDir, "push", "origin", `HEAD:${branch}`], {
882
+ env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
883
+ })
884
+ } catch (e: any) {
885
+ await rollbackSavedKey(userId)
886
+ const stderr = (e?.stderr ?? "").toString().trim()
887
+ const hint = /denied|read.only|permission/i.test(stderr)
888
+ ? " (does the deploy key have write access?)"
889
+ : ""
890
+ return { ok: false, error: `push failed${hint}: ${stderr || e?.message || e}` }
891
+ }
892
+
893
+ return { ok: true, cryptKey: cryptKeyB64 }
894
+ }
895
+
896
+ async function rollbackSavedKey(userId: string) {
897
+ const { rm: rmFile } = await import("node:fs/promises")
898
+ await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {})
899
+ }
900
+
901
+ async function appendLineIfMissing(
902
+ path: string,
903
+ line: string,
904
+ alreadyPresent: (existing: string, line: string) => boolean,
905
+ ) {
906
+ let existing = ""
907
+ try {
908
+ existing = await readFile(path, "utf8")
909
+ } catch {}
910
+ if (alreadyPresent(existing, line)) return
911
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"
912
+ await writeFile(path, existing + sep + line + "\n")
913
+ }
914
+
915
+ async function listTrackedSecretFiles(repoDir: string): Promise<string[]> {
916
+ try {
917
+ const { stdout } = await execFileP("git", [
918
+ "-C",
919
+ repoDir,
920
+ "ls-files",
921
+ "-z",
922
+ ".loopat/vaults",
923
+ ])
924
+ return stdout
925
+ .split("\0")
926
+ .filter(Boolean)
927
+ // Scaffold marker files are not real content; ignore them.
928
+ .filter((f) => !f.endsWith("/.gitkeep"))
929
+ } catch {
930
+ return []
931
+ }
932
+ }
933
+
934
+ export type PersonalDirtyStatus = {
935
+ uncommitted: number
936
+ unpushed: number
937
+ isGitRepo: boolean
938
+ hasRemote: boolean
939
+ }
940
+
941
+ /**
942
+ * Inspect personal/<user>/: how many uncommitted worktree changes, how many
943
+ * commits not reachable from any remote-tracking branch. Used as the
944
+ * pre-flight before a destructive delete.
945
+ *
946
+ * Returns counts only; the caller decides what "dirty" means (we treat
947
+ * uncommitted > 0 || unpushed > 0 as dirty).
948
+ */
949
+ export async function inspectPersonalDirty(userId: string): Promise<PersonalDirtyStatus> {
950
+ const dir = personalDir(userId)
951
+ if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
952
+ return { uncommitted: 0, unpushed: 0, isGitRepo: false, hasRemote: false }
953
+ }
954
+ let hasRemote = false
955
+ try {
956
+ const { stdout } = await execFileP("git", ["-C", dir, "remote"])
957
+ hasRemote = stdout.trim().length > 0
958
+ } catch {}
959
+
960
+ // Refresh remote-tracking refs so "unpushed" reflects current remote state.
961
+ // Best-effort — offline / no network is fine, we'll just over-report.
962
+ if (hasRemote) {
963
+ try {
964
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], {
965
+ env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
966
+ timeout: 15_000,
967
+ })
968
+ } catch {}
969
+ }
970
+
971
+ let uncommitted = 0
972
+ try {
973
+ const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
974
+ uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
975
+ } catch {}
976
+
977
+ let unpushed = 0
978
+ try {
979
+ // Commits on HEAD not reachable from any remote-tracking branch.
980
+ const { stdout } = await execFileP("git", [
981
+ "-C",
982
+ dir,
983
+ "rev-list",
984
+ "--count",
985
+ "HEAD",
986
+ "--not",
987
+ "--remotes",
988
+ ])
989
+ unpushed = parseInt(stdout.trim(), 10) || 0
990
+ } catch {
991
+ // No commits at all on HEAD → rev-list errors; treat as 0
992
+ }
993
+
994
+ return { uncommitted, unpushed, isGitRepo: true, hasRemote }
995
+ }
996
+
997
+ /**
998
+ * Stage + commit + push everything in personal/<user>/. Best-effort. If
999
+ * there's nothing to commit but there are unpushed commits, just push.
1000
+ */
1001
+ export async function syncPersonalToRemote(
1002
+ userId: string,
1003
+ ): Promise<{ ok: true } | { ok: false, error: string }> {
1004
+ const dir = personalDir(userId)
1005
+ if (!existsSyncBase(join(dir, ".git"))) {
1006
+ return { ok: false, error: "personal/ is not a git repo — nothing to sync to" }
1007
+ }
1008
+
1009
+ // Author must be set for the commit step. Set locally so we don't rely
1010
+ // on the host's global git config.
1011
+ try {
1012
+ await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"])
1013
+ await execFileP("git", ["-C", dir, "config", "user.name", "loopat"])
1014
+ } catch (e: any) {
1015
+ return { ok: false, error: `git config failed: ${e?.message ?? e}` }
1016
+ }
1017
+
1018
+ // Stage everything
1019
+ try {
1020
+ await execFileP("git", ["-C", dir, "add", "-A"])
1021
+ } catch (e: any) {
1022
+ return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` }
1023
+ }
1024
+
1025
+ // Commit if there's anything staged. `git diff --cached --quiet` exits
1026
+ // non-zero when there are staged changes, so we invert the check.
1027
+ let hadStaged = false
1028
+ try {
1029
+ await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"])
1030
+ } catch {
1031
+ hadStaged = true
1032
+ }
1033
+ if (hadStaged) {
1034
+ try {
1035
+ await execFileP("git", [
1036
+ "-C",
1037
+ dir,
1038
+ "commit",
1039
+ "-m",
1040
+ "loopat: sync personal vault before delete",
1041
+ ])
1042
+ } catch (e: any) {
1043
+ return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` }
1044
+ }
1045
+ }
1046
+
1047
+ // Determine target branch
1048
+ let branch = "main"
1049
+ try {
1050
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1051
+ const v = stdout.trim()
1052
+ if (v) branch = v
1053
+ } catch {}
1054
+
1055
+ // Need an origin to push to. If there's no remote (e.g. the user never
1056
+ // imported, personal/ is the local-only scaffold), refuse — sync is
1057
+ // impossible. Caller can still force-delete.
1058
+ let hasOrigin = false
1059
+ try {
1060
+ await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
1061
+ hasOrigin = true
1062
+ } catch {}
1063
+ if (!hasOrigin) {
1064
+ return { ok: false, error: "no remote configured — nothing to sync to" }
1065
+ }
1066
+
1067
+ try {
1068
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
1069
+ env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
1070
+ })
1071
+ } catch (e: any) {
1072
+ const stderr = (e?.stderr ?? "").toString().trim()
1073
+ return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
1074
+ }
1075
+ return { ok: true }
1076
+ }
1077
+
1078
+ /**
1079
+ * ff-only sync core — the loop-outside (no-AI) rule from docs/context-flow.md:
1080
+ * rebase a checkout's local commits onto origin/<branch>. A clean rebase means
1081
+ * local is now origin + local commits, linear, ready to ff-push. A real
1082
+ * same-spot conflict is *held back*: we abort (local commits preserved —
1083
+ * nothing is lost) and report the files so the caller can surface the choice
1084
+ * (discard local / take remote / resolve in a loop). Never a blind merge.
1085
+ */
1086
+ async function rebaseOntoOrigin(
1087
+ dir: string,
1088
+ branch: string,
1089
+ sshCommand?: string,
1090
+ ): Promise<{ ok: true } | { ok: false; error: string } | { ok: false; conflict: true; files: string[] }> {
1091
+ const fetchEnv: Record<string, string> = { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1092
+ if (sshCommand) fetchEnv.GIT_SSH_COMMAND = sshCommand
1093
+ try {
1094
+ await execFileP("git", ["-C", dir, "fetch", "origin"], { env: fetchEnv, timeout: 30_000 })
1095
+ } catch (e: any) {
1096
+ return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
1097
+ }
1098
+ // No upstream branch yet (empty remote) → nothing to rebase onto.
1099
+ try {
1100
+ await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `origin/${branch}`])
1101
+ } catch {
1102
+ return { ok: true }
1103
+ }
1104
+ try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {}
1105
+ try {
1106
+ await execFileP("git", ["-C", dir, "rebase", `origin/${branch}`], {
1107
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
1108
+ })
1109
+ return { ok: true }
1110
+ } catch (e: any) {
1111
+ const stderr = (e?.stderr ?? "").toString()
1112
+ let files: string[] = []
1113
+ try {
1114
+ const { stdout } = await execFileP("git", ["-C", dir, "diff", "--name-only", "--diff-filter=U"])
1115
+ files = stdout.split("\n").filter((l) => l.trim())
1116
+ } catch {}
1117
+ try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {}
1118
+ if (files.length > 0 || /CONFLICT/.test(stderr)) return { ok: false, conflict: true, files }
1119
+ return { ok: false, error: `rebase failed: ${stderr || e?.message || e}` }
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Stage + commit local changes. Preserves the repo's existing author (set at
1125
+ * import from the platform identity — some hosts reject non-corporate emails);
1126
+ * only falls back to a local identity if none is configured.
1127
+ */
1128
+ async function commitLocalChanges(
1129
+ dir: string,
1130
+ message: string,
1131
+ ): Promise<{ ok: true; committed: boolean } | { ok: false; error: string }> {
1132
+ try {
1133
+ try { await execFileP("git", ["-C", dir, "config", "user.email"]) }
1134
+ catch { await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"]) }
1135
+ try { await execFileP("git", ["-C", dir, "config", "user.name"]) }
1136
+ catch { await execFileP("git", ["-C", dir, "config", "user.name", "loopat"]) }
1137
+ await execFileP("git", ["-C", dir, "add", "-A"])
1138
+ } catch (e: any) {
1139
+ return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` }
1140
+ }
1141
+ let staged = false
1142
+ try { await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"]) } catch { staged = true }
1143
+ if (!staged) return { ok: true, committed: false }
1144
+ try {
1145
+ await execFileP("git", ["-C", dir, "commit", "-m", message])
1146
+ } catch (e: any) {
1147
+ return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` }
1148
+ }
1149
+ return { ok: true, committed: true }
1150
+ }
1151
+
1152
+ /**
1153
+ * Pull = align this checkout to origin (the SoT). Commits local edits, rebases
1154
+ * them onto origin/<branch> (held back on real conflict). With `force`, discards
1155
+ * local entirely and takes the remote — the "take remote" escape hatch.
1156
+ */
1157
+ export type PersonalPullResult =
1158
+ | { ok: true; message: string }
1159
+ | { ok: false; error: string; conflict?: boolean; files?: string[]; needsStash?: boolean }
1160
+
1161
+ export async function pullPersonalFromRemote(
1162
+ userId: string,
1163
+ opts?: { force?: boolean },
1164
+ ): Promise<PersonalPullResult> {
1165
+ const force = opts?.force ?? false
1166
+ const dir = personalDir(userId)
1167
+ if (!existsSyncBase(join(dir, ".git"))) {
1168
+ return { ok: false, error: "personal/ is not a git repo" }
1169
+ }
1170
+ let hasOrigin = false
1171
+ try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {}
1172
+ if (!hasOrigin) return { ok: false, error: "no remote configured" }
1173
+
1174
+ let branch = "main"
1175
+ try {
1176
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1177
+ if (stdout.trim()) branch = stdout.trim()
1178
+ } catch {}
1179
+
1180
+ if (force) {
1181
+ // "Take the remote": discard ALL local state, re-align to origin. Doubles as
1182
+ // the escape hatch for a wedged repo (stuck rebase/merge, dirty index).
1183
+ const silent = { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" }
1184
+ try {
1185
+ try { await execFileP("git", ["-C", dir, "rebase", "--abort"], { env: silent }) } catch {}
1186
+ try { await execFileP("git", ["-C", dir, "merge", "--abort"], { env: silent }) } catch {}
1187
+ await execFileP("git", ["-C", dir, "fetch", "origin"], {
1188
+ env: { ...silent, GIT_SSH_COMMAND: sshCommandForUser(userId) }, timeout: 30_000,
1189
+ })
1190
+ await execFileP("git", ["-C", dir, "reset", "--hard", `origin/${branch}`], { env: silent })
1191
+ await execFileP("git", ["-C", dir, "clean", "-fd"], { env: silent })
1192
+ return { ok: true, message: `reset to origin/${branch}` }
1193
+ } catch (e: any) {
1194
+ return { ok: false, error: `force pull failed: ${e?.stderr ?? e?.message ?? e}` }
1195
+ }
1196
+ }
1197
+
1198
+ // Normal pull: commit local edits so the tree is clean, then rebase onto origin.
1199
+ const c = await commitLocalChanges(dir, "loopat: local personal edits")
1200
+ if (!c.ok) return { ok: false, error: c.error }
1201
+ const reb = await rebaseOntoOrigin(dir, branch, sshCommandForUser(userId))
1202
+ if (!reb.ok) {
1203
+ if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
1204
+ return { ok: false, error: reb.error }
1205
+ }
1206
+ return { ok: true, message: `aligned to origin/${branch}` }
1207
+ }
1208
+
1209
+ /**
1210
+ * Push = land this checkout on origin (the SoT). Commits local edits, rebases
1211
+ * onto origin/<branch> (held back on a real conflict — never a blind merge),
1212
+ * then ff-pushes. Outside a loop there's no AI, so a conflict is surfaced
1213
+ * (`conflict` + `files`), not swallowed.
1214
+ */
1215
+ export type PersonalPushResult =
1216
+ | { ok: true; message: string }
1217
+ | { ok: false; error: string; conflict?: boolean; files?: string[]; needsPull?: boolean }
1218
+
1219
+ export async function pushPersonalToRemote(
1220
+ userId: string,
1221
+ ): Promise<PersonalPushResult> {
1222
+ const dir = personalDir(userId)
1223
+ if (!existsSyncBase(join(dir, ".git"))) {
1224
+ return { ok: false, error: "personal/ is not a git repo" }
1225
+ }
1226
+ let hasOrigin = false
1227
+ try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {}
1228
+ if (!hasOrigin) return { ok: false, error: "no remote configured" }
1229
+
1230
+ let branch = "main"
1231
+ try {
1232
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1233
+ if (stdout.trim()) branch = stdout.trim()
1234
+ } catch {}
1235
+
1236
+ const c = await commitLocalChanges(dir, "loopat: sync personal vault")
1237
+ if (!c.ok) return { ok: false, error: c.error }
1238
+ const reb = await rebaseOntoOrigin(dir, branch, sshCommandForUser(userId))
1239
+ if (!reb.ok) {
1240
+ if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
1241
+ return { ok: false, error: reb.error }
1242
+ }
1243
+ try {
1244
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
1245
+ env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
1246
+ })
1247
+ } catch (e: any) {
1248
+ const stderr = (e?.stderr ?? "").toString().trim()
1249
+ // We just rebased onto origin, so a rejection means the remote moved again
1250
+ // between rebase and push (rare) — caller can simply retry.
1251
+ return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
1252
+ }
1253
+ return { ok: true, message: c.committed ? "committed and pushed" : "pushed" }
1254
+ }
1255
+
1256
+ /**
1257
+ * UI-loop notes worktree: a per-user checkout of notes, opened from origin/main,
1258
+ * for editing team notes outside any AI loop (the no-AI "UI loop"). Disposable —
1259
+ * rebuilt from origin if missing.
1260
+ */
1261
+ export async function ensureUiNotesWorktree(user: string): Promise<void> {
1262
+ await ensureContextWorktree(workspaceNotesDir(), uiNotesDir(user), `ui/${user}`)
1263
+ }
1264
+
1265
+ /**
1266
+ * Save = land this user's notes edits on origin/main (the SoT). Commits, rebases
1267
+ * onto origin/main (held back on a real conflict), ff-pushes HEAD:main. notes
1268
+ * uses the host's default git auth (team origin), not a personal deploy key.
1269
+ */
1270
+ export async function syncUiNotes(user: string): Promise<PersonalPushResult> {
1271
+ const dir = uiNotesDir(user)
1272
+ await ensureUiNotesWorktree(user)
1273
+ const c = await commitLocalChanges(dir, "loopat: edit notes")
1274
+ if (!c.ok) return { ok: false, error: c.error }
1275
+ const reb = await rebaseOntoOrigin(dir, "main")
1276
+ if (!reb.ok) {
1277
+ if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
1278
+ return { ok: false, error: reb.error }
1279
+ }
1280
+ try {
1281
+ await execFileP("git", ["-C", dir, "push", "origin", "HEAD:main"])
1282
+ } catch (e: any) {
1283
+ const stderr = (e?.stderr ?? "").toString().trim()
1284
+ return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
1285
+ }
1286
+ return { ok: true, message: c.committed ? "saved & pushed" : "pushed" }
1287
+ }
1288
+
1289
+ // ── Generic repo sync (knowledge / notes / repos) ─────────────────────
1290
+ //
1291
+ // Distinct from personal sync above: these workspace-level repos use the
1292
+ // host's default SSH config (whatever the server clone used at boot), NOT
1293
+ // a per-user deploy key. Strict ff-only on both directions — by design no
1294
+ // one edits these outside of loopat, so divergence is treated as an error
1295
+ // to investigate, not auto-resolved.
1296
+
1297
+ export type RepoSyncStatus = {
1298
+ isGitRepo: boolean
1299
+ hasRemote: boolean
1300
+ branch: string
1301
+ ahead: number
1302
+ behind: number
1303
+ uncommitted: number
1304
+ }
1305
+
1306
+ export type RepoSyncResult =
1307
+ | { ok: true; message: string }
1308
+ | { ok: false; error: string }
1309
+
1310
+ /**
1311
+ * Best-effort fetch then count ahead/behind vs origin/<branch>. Fetch
1312
+ * failures are tolerated (offline / auth glitch) — status still reflects
1313
+ * last-known remote state.
1314
+ */
1315
+ export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
1316
+ if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
1317
+ return { isGitRepo: false, hasRemote: false, branch: "", ahead: 0, behind: 0, uncommitted: 0 }
1318
+ }
1319
+
1320
+ let branch = ""
1321
+ try {
1322
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1323
+ branch = stdout.trim()
1324
+ } catch {}
1325
+
1326
+ let hasRemote = false
1327
+ try {
1328
+ await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
1329
+ hasRemote = true
1330
+ } catch {}
1331
+
1332
+ if (hasRemote) {
1333
+ try {
1334
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { timeout: 15_000 })
1335
+ } catch {}
1336
+ }
1337
+
1338
+ let uncommitted = 0
1339
+ try {
1340
+ const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
1341
+ uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
1342
+ } catch {}
1343
+
1344
+ let ahead = 0
1345
+ let behind = 0
1346
+ if (hasRemote && branch) {
1347
+ try {
1348
+ const { stdout } = await execFileP("git", [
1349
+ "-C", dir, "rev-list", "--left-right", "--count", `origin/${branch}...${branch}`,
1350
+ ])
1351
+ const m = stdout.trim().match(/^(\d+)\s+(\d+)$/)
1352
+ if (m) { behind = parseInt(m[1], 10); ahead = parseInt(m[2], 10) }
1353
+ } catch {}
1354
+ }
1355
+
1356
+ return { isGitRepo: true, hasRemote, branch, ahead, behind, uncommitted }
1357
+ }
1358
+
1359
+ /**
1360
+ * Fetch + ff-only merge into the current HEAD. Aborts on uncommitted
1361
+ * changes (we don't auto-stash workspace repos — caller decides) and on
1362
+ * any non-ff condition.
1363
+ */
1364
+ export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
1365
+ if (!existsSyncBase(join(dir, ".git"))) {
1366
+ return { ok: false, error: "not a git repo" }
1367
+ }
1368
+
1369
+ let hasRemote = false
1370
+ try {
1371
+ await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
1372
+ hasRemote = true
1373
+ } catch {}
1374
+ if (!hasRemote) return { ok: false, error: "no remote configured" }
1375
+
1376
+ let branch = ""
1377
+ try {
1378
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1379
+ branch = stdout.trim()
1380
+ } catch {}
1381
+ if (!branch) return { ok: false, error: "HEAD is detached" }
1382
+
1383
+ let uncommitted = 0
1384
+ try {
1385
+ const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
1386
+ uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
1387
+ } catch {}
1388
+ if (uncommitted > 0) {
1389
+ return { ok: false, error: `aborted: ${uncommitted} uncommitted change(s) in primary` }
1390
+ }
1391
+
1392
+ try {
1393
+ await execFileP("git", ["-C", dir, "fetch", "origin"], { timeout: 30_000 })
1394
+ } catch (e: any) {
1395
+ return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
1396
+ }
1397
+
1398
+ try {
1399
+ await execFileP("git", ["-C", dir, "merge", "--ff-only", `origin/${branch}`])
1400
+ } catch (e: any) {
1401
+ const stderr = (e?.stderr ?? "").toString().trim()
1402
+ return { ok: false, error: `merge --ff-only failed (diverged from origin/${branch}?): ${stderr || e?.message || e}` }
1403
+ }
1404
+
1405
+ return { ok: true, message: `pulled origin/${branch}` }
1406
+ }
1407
+
1408
+ /**
1409
+ * Push current HEAD branch to origin. Plain `git push` — git refuses
1410
+ * non-ff by default, which is exactly the abort-on-conflict behavior we
1411
+ * want. Caller pulls first if rejected.
1412
+ */
1413
+ export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
1414
+ if (!existsSyncBase(join(dir, ".git"))) {
1415
+ return { ok: false, error: "not a git repo" }
1416
+ }
1417
+
1418
+ let hasRemote = false
1419
+ try {
1420
+ await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
1421
+ hasRemote = true
1422
+ } catch {}
1423
+ if (!hasRemote) return { ok: false, error: "no remote configured" }
1424
+
1425
+ let branch = ""
1426
+ try {
1427
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
1428
+ branch = stdout.trim()
1429
+ } catch {}
1430
+ if (!branch) return { ok: false, error: "HEAD is detached" }
1431
+
1432
+ try {
1433
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`])
1434
+ } catch (e: any) {
1435
+ const stderr = (e?.stderr ?? "").toString().trim()
1436
+ return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
1437
+ }
1438
+
1439
+ return { ok: true, message: `pushed to origin/${branch}` }
1440
+ }
1441
+
1442
+ /**
1443
+ * Wipe personal/<user>/ AND the saved git-crypt key. Deploy keypair stays
1444
+ * (it's the SSH identity, reusable for the next import). Re-scaffolds an
1445
+ * empty git-init'd personal/<user>/ so workspace bind paths still resolve.
1446
+ */
1447
+ export async function deletePersonalVault(userId: string): Promise<{ ok: true } | { ok: false; error: string }> {
1448
+ const dir = personalDir(userId)
1449
+ try {
1450
+ await rm(dir, { recursive: true, force: true })
1451
+ } catch (e: any) {
1452
+ return { ok: false, error: `rm personal/ failed: ${e?.message ?? e}` }
1453
+ }
1454
+ const { rm: rmFile } = await import("node:fs/promises")
1455
+ await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {})
1456
+
1457
+ // Re-scaffold empty so the workspace doesn't have a hole. Mirrors
1458
+ // provisionUserPersonal but without re-running deploy-key gen.
1459
+ try {
1460
+ await mkdir(dir, { recursive: true })
1461
+ const pm = personalMemoryDir(userId)
1462
+ await mkdir(pm, { recursive: true })
1463
+ const pmIdx = `${pm}/MEMORY.md`
1464
+ if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
1465
+ await gitInitIfMissing(dir)
1466
+ } catch (e: any) {
1467
+ return { ok: false, error: `re-scaffold failed: ${e?.message ?? e}` }
1468
+ }
1469
+ return { ok: true }
1470
+ }
1471
+
1472
+ // git-crypt's per-file magic header (10 bytes): \x00 G I T C R Y P T \x00
1473
+ const GIT_CRYPT_MAGIC = Buffer.from([0x00, 0x47, 0x49, 0x54, 0x43, 0x52, 0x59, 0x50, 0x54, 0x00])
1474
+
1475
+ /**
1476
+ * Returns tracked files under `.loopat/vaults/**` that are stored as
1477
+ * plaintext (i.e., the worktree blob doesn't start with the git-crypt magic
1478
+ * header). Reads the worktree directly: in a fresh clone where git-crypt
1479
+ * isn't unlocked, the worktree contents ARE the raw blobs, so non-encrypted
1480
+ * files are visibly plaintext here.
1481
+ */
1482
+ async function detectExposedSecrets(repoDir: string): Promise<string[]> {
1483
+ // Anything under `.loopat/vaults/` stored as plaintext is an exposure and
1484
+ // refuses import.
1485
+ //
1486
+ // Symlinks are skipped: git stores a symlink's target as the blob, and
1487
+ // git-crypt's filter doesn't (and can't) encrypt that. The target path
1488
+ // itself isn't a secret value — and walkVaultFiles refuses to bind any
1489
+ // symlink whose realpath escapes personal/<user>/.
1490
+ const exposed: string[] = []
1491
+ let stdout = ""
1492
+ try {
1493
+ const r = await execFileP("git", ["-C", repoDir, "ls-files", "-z", ".loopat/vaults"])
1494
+ stdout = r.stdout
1495
+ } catch {
1496
+ return exposed
1497
+ }
1498
+ const files = stdout.split("\0").filter(Boolean)
1499
+ for (const f of files) {
1500
+ if (f.endsWith("/.gitkeep")) continue
1501
+ try {
1502
+ const lst = await lstat(join(repoDir, f))
1503
+ if (lst.isSymbolicLink()) continue
1504
+ const buf = await readFile(join(repoDir, f))
1505
+ if (buf.length === 0) continue
1506
+ if (!buf.subarray(0, GIT_CRYPT_MAGIC.length).equals(GIT_CRYPT_MAGIC)) {
1507
+ exposed.push(f)
1508
+ }
1509
+ } catch {
1510
+ // unreadable — skip
1511
+ }
1512
+ }
1513
+ return exposed
1514
+ }
1515
+
1516
+ async function detectGitCryptEnabled(repoDir: string): Promise<boolean> {
1517
+ try {
1518
+ const { stdout } = await execFileP("git", ["-C", repoDir, "ls-files", "-z"])
1519
+ const files = stdout.split("\0").filter((f) => f.endsWith(".gitattributes"))
1520
+ for (const f of files) {
1521
+ try {
1522
+ const content = await readFile(join(repoDir, f), "utf8")
1523
+ if (/filter=git-crypt/.test(content)) return true
1524
+ } catch {}
1525
+ }
1526
+ return false
1527
+ } catch {
1528
+ return false
1529
+ }
1530
+ }
1531
+
1532
+ /**
1533
+ * Persist cryptKey (base64) to host-secrets/<user>/git-crypt.key and run
1534
+ * `git-crypt unlock` against the cloned repo. On failure, removes the saved
1535
+ * keyfile so a retry can paste a different key.
1536
+ */
1537
+ async function unlockWithCryptKey(
1538
+ repoDir: string,
1539
+ userId: string,
1540
+ cryptKeyB64: string,
1541
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
1542
+ const { saveGitCryptKey, gitCryptKeyExists } = await import("./git-crypt-key")
1543
+ try {
1544
+ const keyBuf = Buffer.from(cryptKeyB64.trim(), "base64")
1545
+ if (keyBuf.length < 32) {
1546
+ return { ok: false, error: "invalid git-crypt key (too short — must be base64-encoded export-key output)" }
1547
+ }
1548
+ await saveGitCryptKey(userId, keyBuf)
1549
+ } catch (e: any) {
1550
+ return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` }
1551
+ }
1552
+ const keyPath = personalGitCryptKeyPath(userId)
1553
+ try {
1554
+ await execFileP("git-crypt", ["unlock", keyPath], { cwd: repoDir })
1555
+ return { ok: true }
1556
+ } catch (e: any) {
1557
+ if (await gitCryptKeyExists(userId)) {
1558
+ const { rm: rmFile } = await import("node:fs/promises")
1559
+ await rmFile(keyPath, { force: true }).catch(() => {})
1560
+ }
1561
+ const stderr = (e?.stderr ?? "").toString().trim()
1562
+ if (/not the file you generated/i.test(stderr) || /Invalid key file/i.test(stderr)) {
1563
+ return { ok: false, error: "git-crypt unlock failed: wrong key (HMAC mismatch)" }
1564
+ }
1565
+ if (/command not found/i.test(stderr) || e?.code === "ENOENT") {
1566
+ return { ok: false, error: "git-crypt not installed on host (apt install git-crypt)" }
1567
+ }
1568
+ return { ok: false, error: `git-crypt unlock failed: ${stderr || e?.message || e}` }
1569
+ }
1570
+ }
1571
+
1572
+ async function ensureSymlink(link: string, target: string) {
1573
+ try {
1574
+ await lstat(link)
1575
+ } catch {
1576
+ await symlink(target, link, "dir")
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Idempotently materialize a per-loop git worktree of `repo` at `path` on
1582
+ * branch `branchName`. If the path already holds a worktree, no-op. If the
1583
+ * source isn't a git repo (e.g., knowledge without a remote), fall back to
1584
+ * a symlink so the path still resolves — those loops can't publish, but
1585
+ * read access still works.
1586
+ */
1587
+ async function ensureContextWorktree(repo: string, path: string, branchName: string) {
1588
+ let stats: Awaited<ReturnType<typeof lstat>> | null = null
1589
+ try { stats = await lstat(path) } catch {}
1590
+ // Real dir with .git → already a worktree, leave it alone.
1591
+ if (stats?.isDirectory() && existsSyncBase(join(path, ".git"))) return
1592
+
1593
+ // Source isn't a git repo — fall back to symlink (legacy shape).
1594
+ if (!existsSyncBase(join(repo, ".git"))) {
1595
+ try { await rm(path, { recursive: true, force: true }) } catch {}
1596
+ await ensureSymlink(path, repo)
1597
+ return
1598
+ }
1599
+
1600
+ // Stale state (old symlink, empty dir, leftover from manual cleanup) → wipe + create.
1601
+ try { await rm(path, { recursive: true, force: true }) } catch {}
1602
+ // ① pull (docs/context-flow.md): open the worktree from origin/main so the
1603
+ // loop starts from latest consensus, not a possibly-stale local HEAD.
1604
+ const start = await remoteStartPoint(repo)
1605
+ const args = ["-C", repo, "worktree", "add", "-b", branchName, path]
1606
+ if (start) args.push(start)
1607
+ await execFileP("git", args)
1608
+ }
1609
+
1610
+ /**
1611
+ * ① pull, per docs/context-flow.md: a loop starts from consensus. Best-effort
1612
+ * fetch origin, then return `origin/main` as the worktree start-point so the
1613
+ * loop opens from the latest shared state. Returns null to fall back to local
1614
+ * HEAD (solo / offline / no remote / no origin/main yet).
1615
+ */
1616
+ async function remoteStartPoint(repo: string): Promise<string | null> {
1617
+ try {
1618
+ await execFileP("git", ["-C", repo, "remote", "get-url", "origin"])
1619
+ } catch {
1620
+ return null
1621
+ }
1622
+ try {
1623
+ await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { timeout: 15_000 })
1624
+ } catch {}
1625
+ try {
1626
+ await execFileP("git", ["-C", repo, "rev-parse", "--verify", "--quiet", "origin/main^{commit}"])
1627
+ return "origin/main"
1628
+ } catch {
1629
+ return null
1630
+ }
1631
+ }
1632
+
1633
+ export async function ensureContextMounts(id: string, createdBy: string) {
1634
+ await mkdir(loopContextDir(id), { recursive: true })
1635
+ await ensureContextWorktree(workspaceKnowledgeDir(), loopContextKnowledge(id), `loop/${id}`)
1636
+ await ensureContextWorktree(workspaceNotesDir(), loopContextNotes(id), `loop/${id}`)
1637
+ // personal is also a per-loop worktree (docs/context-flow.md) — same shape as
1638
+ // notes, just wired to the user's private remote. ensureContextWorktree falls
1639
+ // back to a symlink when personal/ isn't a git repo yet.
1640
+ await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
1641
+ await ensureSymlink(loopContextRepos(id), workspaceReposDir())
1642
+ }
1643
+
1644
+ export async function listLoops(): Promise<LoopMeta[]> {
1645
+ try {
1646
+ const ids = await readdir(loopsDir())
1647
+ const metas = await Promise.all(
1648
+ ids.map(async (id) => {
1649
+ try {
1650
+ const raw = await readFile(loopMetaPath(id), "utf8")
1651
+ return JSON.parse(raw) as LoopMeta
1652
+ } catch {
1653
+ return null
1654
+ }
1655
+ })
1656
+ )
1657
+ return metas.filter((m): m is LoopMeta => m !== null).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1658
+ } catch (e: any) {
1659
+ if (e?.code === "ENOENT") return []
1660
+ throw e
1661
+ }
1662
+ }
1663
+
1664
+ // refreshLoopSandbox removed entirely — profile model re-composes every spawn.
1665
+
1666
+ async function shortBranchSlug(title: string): Promise<string> {
1667
+ const base = title
1668
+ .toLowerCase()
1669
+ .replace(/[^a-z0-9]+/g, "-")
1670
+ .replace(/^-+|-+$/g, "")
1671
+ .slice(0, 32)
1672
+ return base || "loop"
1673
+ }
1674
+
1675
+ export async function createLoop(opts: {
1676
+ title: string
1677
+ repo?: string
1678
+ createdBy: string
1679
+ profiles?: string[]
1680
+ vault?: string
1681
+ knowledgeRw?: boolean
1682
+ mountAllLoops?: boolean
1683
+ }): Promise<LoopMeta> {
1684
+ await ensureWorkspaceDirs()
1685
+ const id = randomUUID()
1686
+ const createdAt = new Date().toISOString()
1687
+ const meta: LoopMeta = {
1688
+ id,
1689
+ title: opts.title.trim() || "untitled",
1690
+ createdAt,
1691
+ createdBy: opts.createdBy,
1692
+ driver: opts.createdBy,
1693
+ driverHistory: [{ driver: opts.createdBy, since: createdAt }],
1694
+ }
1695
+ if (opts.profiles && opts.profiles.length > 0) {
1696
+ meta.config = { ...(meta.config ?? {}), profiles: opts.profiles }
1697
+ }
1698
+ if (opts.vault && opts.vault !== "default") {
1699
+ meta.config = { ...(meta.config ?? {}), vault: opts.vault }
1700
+ }
1701
+ if (opts.knowledgeRw) {
1702
+ meta.config = { ...(meta.config ?? {}), knowledge_rw: true }
1703
+ }
1704
+ if (opts.mountAllLoops) {
1705
+ meta.config = { ...(meta.config ?? {}), mount_all_loops: true }
1706
+ }
1707
+ await mkdir(loopDir(id), { recursive: true })
1708
+ await mkdir(loopClaudeDir(id), { recursive: true })
1709
+ // Compose skills/agents + profile-chain doctrine into .claude/, write
1710
+ // settings.json (autoMemory). Plugin resolution happens at spawn time
1711
+ // (see session.ts) — SDK loads plugins via its `plugins` option, no
1712
+ // loop-local install state needed.
1713
+ await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
1714
+ await writeLoopSettings(id)
1715
+
1716
+ // workdir = git worktree add (if repo selected) OR plain mkdir
1717
+ if (opts.repo) {
1718
+ // clone-on-demand: pull the repo down only now that a loop actually needs it
1719
+ if (!(await ensureRepoCloned(opts.repo))) {
1720
+ throw new Error(`repo "${opts.repo}" not found / clone failed`)
1721
+ }
1722
+ const repoPath = workspaceRepoDir(opts.repo)
1723
+ const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
1724
+ try {
1725
+ // ① pull (docs/context-flow.md): base the workdir branch on origin/main
1726
+ // (best-effort fetch) so it starts from latest consensus; fall back to
1727
+ // local HEAD when there's no remote / no origin/main.
1728
+ const start = await remoteStartPoint(repoPath)
1729
+ const wtArgs = ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]
1730
+ if (start) wtArgs.push(start)
1731
+ await execFileP("git", wtArgs)
1732
+ meta.repo = opts.repo
1733
+ meta.branch = branch
1734
+ } catch (e: any) {
1735
+ // fallback: plain mkdir (let user know)
1736
+ console.warn(`[loopat] git worktree add failed for repo=${opts.repo}: ${e?.stderr ?? e?.message}`)
1737
+ await mkdir(loopWorkdir(id), { recursive: true })
1738
+ }
1739
+ } else {
1740
+ await mkdir(loopWorkdir(id), { recursive: true })
1741
+ }
1742
+
1743
+ await ensureContextMounts(id, effectiveDriver(meta))
1744
+ await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
1745
+ return meta
1746
+ }
1747
+
1748
+ /**
1749
+ * Spawn a child "distill loop" from a source loop. The child's workdir gets
1750
+ * a point-in-time snapshot of the source's conversation files plus a
1751
+ * project-tier CLAUDE.md telling the AI it's a distill loop. Knowledge is
1752
+ * rw so the child can publish sedimented insights. The source is not
1753
+ * touched. Any authenticated user may distill any loop — distill is a
1754
+ * read-only relationship.
1755
+ */
1756
+ export async function distillLoop(sourceId: string, byUser: string): Promise<LoopMeta> {
1757
+ const source = await getLoop(sourceId)
1758
+ if (!source) throw new Error(`source loop ${sourceId} not found`)
1759
+
1760
+ const shortId = source.id.slice(0, 6)
1761
+ const child = await createLoop({
1762
+ title: `distill: ${shortId} ${source.title}`,
1763
+ createdBy: byUser,
1764
+ knowledgeRw: true,
1765
+ })
1766
+
1767
+ // Snapshot the source's conversation into the child's workdir.
1768
+ const sourceDir = join(loopWorkdir(child.id), "source")
1769
+ await mkdir(sourceDir, { recursive: true })
1770
+ for (const [from, to] of [
1771
+ [loopHistoryPath(sourceId), join(sourceDir, "messages.jsonl")],
1772
+ [loopChatHistoryPath(sourceId), join(sourceDir, "chat_history.jsonl")],
1773
+ ]) {
1774
+ if (existsSyncBase(from)) {
1775
+ await copyFile(from, to)
1776
+ }
1777
+ }
1778
+
1779
+ // Drop the distill kind's project-tier CLAUDE.md into the workdir. Claude
1780
+ // Code auto-loads <workdir>/CLAUDE.md (settingSources includes "project").
1781
+ const tmpl = loopKindClaudePath("distill")
1782
+ if (existsSyncBase(tmpl)) {
1783
+ await copyFile(tmpl, join(loopWorkdir(child.id), "CLAUDE.md"))
1784
+ }
1785
+
1786
+ return child
1787
+ }
1788
+
1789
+ export async function getLoop(id: string): Promise<LoopMeta | null> {
1790
+ try {
1791
+ const raw = await readFile(loopMetaPath(id), "utf8")
1792
+ return JSON.parse(raw) as LoopMeta
1793
+ } catch {
1794
+ return null
1795
+ }
1796
+ }
1797
+
1798
+ export async function patchLoopMeta(id: string, patch: Partial<LoopMeta>): Promise<LoopMeta | null> {
1799
+ const meta = await getLoop(id)
1800
+ if (!meta) return null
1801
+ const updated = { ...meta, ...patch }
1802
+ await writeFile(loopMetaPath(id), JSON.stringify(updated, null, 2))
1803
+ return updated
1804
+ }
1805
+
1806
+ export async function loopExists(id: string): Promise<boolean> {
1807
+ try {
1808
+ const s = await stat(loopDir(id))
1809
+ return s.isDirectory()
1810
+ } catch {
1811
+ return false
1812
+ }
1813
+ }
1814
+
1815
+ export async function backfillAllMounts(): Promise<number> {
1816
+ let count = 0
1817
+ try {
1818
+ const ids = await readdir(loopsDir())
1819
+ for (const id of ids) {
1820
+ try {
1821
+ const meta = await getLoop(id)
1822
+ if (!meta?.createdBy) {
1823
+ console.warn(`[loopat] loop ${id}: meta missing createdBy — skipping mount backfill`)
1824
+ continue
1825
+ }
1826
+ await ensureContextMounts(id, effectiveDriver(meta))
1827
+ count++
1828
+ } catch {}
1829
+ }
1830
+ } catch {}
1831
+ return count
1832
+ }