loopat 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs"
7
7
  import { execFileSync } from "node:child_process"
8
8
  import { join } from "node:path"
9
9
  import { resolveClaudeBinary } from "./claude-binary"
10
- import { configPath, type WorkspaceConfig } from "./config"
10
+ import { configPath, loadKnowledgeConfig, type WorkspaceConfig, type KnowledgeConfig } from "./config"
11
11
  import {
12
12
  WORKSPACE,
13
13
  usersPath,
@@ -104,8 +104,8 @@ function describeRemote(dir: string, url: string | undefined): string {
104
104
  return "local-only (no remote)"
105
105
  }
106
106
 
107
- function describeRepos(cfg: WorkspaceConfig): Check {
108
- const specs = cfg.repos ?? []
107
+ function describeRepos(kcfg: KnowledgeConfig): Check {
108
+ const specs = kcfg.repos ?? []
109
109
  if (specs.length === 0) return { ok: true, label: `repos: (none configured)` }
110
110
  // Repos are clone-on-demand (cloned only when a loop selects one), so a repo
111
111
  // that isn't cloned yet is NORMAL, not a failure. Report cloned vs on-demand;
@@ -132,12 +132,14 @@ async function checkUsers(): Promise<Check> {
132
132
  }
133
133
 
134
134
  export async function printBootstrapBanner(cfg: WorkspaceConfig) {
135
+ // notes + repos are declared inside the knowledge repo's .loopat/config.json.
136
+ const kcfg = await loadKnowledgeConfig()
135
137
  const checks: Check[] = [
136
138
  { ok: true, label: `workspace: ${workspaceDir()}` },
137
139
  { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
138
140
  { ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` },
139
- { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), cfg.notes?.git || undefined)}` },
140
- describeRepos(cfg),
141
+ { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), kcfg.notes?.git || undefined)}` },
142
+ describeRepos(kcfg),
141
143
  await checkUsers(),
142
144
  { ok: existsSync(configPath()), label: `config: ${configPath()}` },
143
145
  checkPodman(),
@@ -9,6 +9,8 @@ import {
9
9
  personalVaultEnvPath,
10
10
  personalVaultEnvsDir,
11
11
  workspaceDir,
12
+ workspaceLoopatRoot,
13
+ personalKnowledgeLoopatRoot,
12
14
  personalSettingsPath,
13
15
  } from "./paths"
14
16
  import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
@@ -109,6 +111,55 @@ export type RepoSpec = {
109
111
  git: string
110
112
  }
111
113
 
114
+ /**
115
+ * Config living INSIDE the knowledge repo at <knowledge>/.loopat/config.json.
116
+ * The knowledge repo is the SoT for the team's notes remote + the repo roster;
117
+ * workspace/personal config only hold the `knowledge` entry pointer. To read
118
+ * it the knowledge repo must already be cloned (its url comes from
119
+ * personal/workspace config), so this is loaded AFTER the knowledge clone.
120
+ */
121
+ export type KnowledgeConfig = {
122
+ notes?: RemoteSpec
123
+ repos?: RepoSpec[]
124
+ }
125
+
126
+ const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" }, repos: [] }
127
+
128
+ /** The .loopat root holding the knowledge config. With a user → that user's
129
+ * per-user knowledge repo; without → the workspace-default knowledge repo
130
+ * (used only for the bootstrap banner, never to drive a loop). */
131
+ function knowledgeLoopatRoot(user?: string): string {
132
+ return user ? personalKnowledgeLoopatRoot(user) : workspaceLoopatRoot()
133
+ }
134
+
135
+ /** Read the knowledge repo's .loopat/config.json. Missing/malformed → empty.
136
+ * Pass `user` for the per-user knowledge repo (what a loop uses); omit for the
137
+ * workspace-default (bootstrap display only). */
138
+ export async function loadKnowledgeConfig(user?: string): Promise<KnowledgeConfig> {
139
+ const path = join(knowledgeLoopatRoot(user), "config.json")
140
+ if (!existsSync(path)) return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
141
+ try {
142
+ const disk = JSON.parse(await readFile(path, "utf8")) as KnowledgeConfig
143
+ return { notes: disk.notes, repos: Array.isArray(disk.repos) ? disk.repos : [] }
144
+ } catch (e: any) {
145
+ console.warn(`[loopat] knowledge config: ${path} malformed (${e?.message ?? e}), treating as empty`)
146
+ return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
147
+ }
148
+ }
149
+
150
+ /** Merge-write a knowledge repo's .loopat/config.json. Caller is responsible
151
+ * for committing/promoting the change (knowledge is gated). */
152
+ export async function saveKnowledgeConfig(user: string | undefined, patch: KnowledgeConfig): Promise<void> {
153
+ const cur = await loadKnowledgeConfig(user)
154
+ const next: KnowledgeConfig = {
155
+ notes: patch.notes !== undefined ? patch.notes : cur.notes,
156
+ repos: patch.repos !== undefined ? patch.repos : cur.repos,
157
+ }
158
+ const dir = knowledgeLoopatRoot(user)
159
+ await mkdir(dir, { recursive: true })
160
+ await writeFile(join(dir, "config.json"), JSON.stringify(next, null, 2) + "\n")
161
+ }
162
+
112
163
  /** Operator-side mount (workspace config). src is always a literal host path.
113
164
  * Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...`
114
165
  * is allowed (modulo `..` traversal). Used for cross-user shared caches
@@ -128,9 +179,11 @@ export type OperatorMount = {
128
179
  * personal/<user>/.loopat/config.json — see PersonalConfig.
129
180
  */
130
181
  export type WorkspaceConfig = {
182
+ /** Workspace-default entry pointer to the knowledge repo. The notes remote
183
+ * and the repo roster now live INSIDE the knowledge repo's
184
+ * .loopat/config.json (KnowledgeConfig) — see loadKnowledgeConfig. A
185
+ * per-user override lives in personal config. */
131
186
  knowledge?: RemoteSpec
132
- notes?: RemoteSpec
133
- repos?: RepoSpec[]
134
187
  providers?: Record<string, ProviderConfig>
135
188
  default?: string
136
189
  /** Platform-level git host for personal onboarding. `provider` is a
@@ -192,11 +245,10 @@ export type WorkspaceConfig = {
192
245
  export type PersonalConfigDisk = {
193
246
  /** Mixed: "default" key is a string, all other keys are providers. */
194
247
  providers: Record<string, ProviderConfigDisk | string>
195
- /** Authoritative kn/notes remotes the personal repo is self-describing.
196
- * host config.json's knowledge/notes are a display mirror; these are what a
197
- * loop actually connects to (with the user's vault key). */
248
+ /** Per-user entry pointer to the knowledge repo (authoritative over the
249
+ * workspace default). The notes remote + repo roster live inside the
250
+ * knowledge repo's own .loopat/config.json, not here. */
198
251
  knowledge?: RemoteSpec
199
- notes?: RemoteSpec
200
252
  }
201
253
 
202
254
  export type PersonalConfig = {
@@ -209,9 +261,9 @@ export type PersonalConfig = {
209
261
  * in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
210
262
  */
211
263
  vaultEnvs: Record<string, string>
212
- /** Authoritative kn/notes remotes (self-describing personal repo). */
264
+ /** Per-user entry pointer to the knowledge repo (notes/repos live in the
265
+ * knowledge repo's own .loopat/config.json). */
213
266
  knowledge?: RemoteSpec
214
- notes?: RemoteSpec
215
267
  }
216
268
 
217
269
  /**
@@ -252,10 +304,6 @@ function buildPresetProviders(): Record<string, ProviderConfig> {
252
304
 
253
305
  const WORKSPACE_TEMPLATE: WorkspaceConfig = {
254
306
  knowledge: { git: "" },
255
- notes: { git: "" },
256
- repos: [
257
- { name: "loopat", git: "git@github.com:simpx/loopat.git" },
258
- ],
259
307
  providers: buildPresetProviders(),
260
308
  }
261
309
 
@@ -430,7 +478,6 @@ export async function loadPersonalConfig(
430
478
  providers,
431
479
  vaultEnvs,
432
480
  ...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
433
- ...(disk.notes ? { notes: disk.notes } : {}),
434
481
  }
435
482
  personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
436
483
  return cfg
@@ -747,8 +794,6 @@ export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promis
747
794
  }
748
795
  if (cfg.default !== undefined) merged.default = cfg.default
749
796
  if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
750
- if (cfg.notes !== undefined) merged.notes = cfg.notes
751
- if (cfg.repos !== undefined) merged.repos = cfg.repos
752
797
  if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain
753
798
  if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort
754
799
  if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps
@@ -49,6 +49,10 @@ import {
49
49
  workspaceNotesDir,
50
50
  workspaceRepoDir,
51
51
  workspaceReposDir,
52
+ personalKnowledgeDir,
53
+ personalNotesDir,
54
+ personalRepoDir,
55
+ personalReposDir,
52
56
  loopsDir,
53
57
  } from "./paths"
54
58
  import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
@@ -1373,45 +1377,50 @@ app.post("/api/personal/push", requireAuth, async (c) => {
1373
1377
  // All ff-only. Any authenticated user may sync. Push to repos/<name> is
1374
1378
  // not supported — code flows through PRs upstream, never from primary.
1375
1379
 
1376
- function syncDirFor(resource: string, name?: string): string | null {
1377
- if (resource === "knowledge") return workspaceKnowledgeDir()
1378
- if (resource === "notes") return workspaceNotesDir()
1380
+ function syncDirFor(user: string, resource: string, name?: string): string | null {
1381
+ if (resource === "knowledge") return personalKnowledgeDir(user)
1382
+ if (resource === "notes") return personalNotesDir(user)
1379
1383
  if (resource === "repos" && name) {
1380
- const dir = workspaceRepoDir(name)
1384
+ const dir = personalRepoDir(user, name)
1381
1385
  return existsSync(dir) ? dir : null
1382
1386
  }
1383
1387
  return null
1384
1388
  }
1385
1389
 
1386
- app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir(), c.get("userId") as string)))
1390
+ app.get("/api/sync/knowledge/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalKnowledgeDir(u), u)) })
1387
1391
  app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
1388
- const r = await pullRepoFromRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1392
+ const u = c.get("userId") as string
1393
+ const r = await pullRepoFromRemote(personalKnowledgeDir(u), u)
1389
1394
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1390
1395
  })
1391
1396
  app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
1392
- const r = await pushRepoToRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1397
+ const u = c.get("userId") as string
1398
+ const r = await pushRepoToRemote(personalKnowledgeDir(u), u)
1393
1399
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1394
1400
  })
1395
1401
 
1396
- app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir(), c.get("userId") as string)))
1402
+ app.get("/api/sync/notes/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalNotesDir(u), u)) })
1397
1403
  app.post("/api/sync/notes/pull", requireAuth, async (c) => {
1398
- const r = await pullRepoFromRemote(workspaceNotesDir(), c.get("userId") as string)
1404
+ const u = c.get("userId") as string
1405
+ const r = await pullRepoFromRemote(personalNotesDir(u), u)
1399
1406
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1400
1407
  })
1401
1408
  app.post("/api/sync/notes/push", requireAuth, async (c) => {
1402
- const r = await pushRepoToRemote(workspaceNotesDir(), c.get("userId") as string)
1409
+ const u = c.get("userId") as string
1410
+ const r = await pushRepoToRemote(personalNotesDir(u), u)
1403
1411
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1404
1412
  })
1405
1413
 
1406
1414
  app.get("/api/sync/repos", requireAuth, async (c) => {
1407
- // List repos available for sync (just names of subdirs of workspaceReposDir).
1415
+ // List the user's per-user repos available for sync (subdirs of their repos dir).
1416
+ const u = c.get("userId") as string
1408
1417
  try {
1409
1418
  const { readdir } = await import("node:fs/promises")
1410
- const entries = await readdir(workspaceReposDir())
1419
+ const entries = await readdir(personalReposDir(u))
1411
1420
  const repos: string[] = []
1412
1421
  for (const e of entries) {
1413
1422
  if (e.startsWith(".")) continue
1414
- if (existsSync(workspaceRepoDir(e) + "/.git")) repos.push(e)
1423
+ if (existsSync(personalRepoDir(u, e) + "/.git")) repos.push(e)
1415
1424
  }
1416
1425
  return c.json({ repos })
1417
1426
  } catch {
@@ -1419,14 +1428,16 @@ app.get("/api/sync/repos", requireAuth, async (c) => {
1419
1428
  }
1420
1429
  })
1421
1430
  app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
1422
- const dir = syncDirFor("repos", c.req.param("name"))
1431
+ const u = c.get("userId") as string
1432
+ const dir = syncDirFor(u, "repos", c.req.param("name"))
1423
1433
  if (!dir) return c.json({ error: "repo not found" }, 404)
1424
- return c.json(await inspectRepoSync(dir, c.get("userId") as string))
1434
+ return c.json(await inspectRepoSync(dir, u))
1425
1435
  })
1426
1436
  app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
1427
- const dir = syncDirFor("repos", c.req.param("name"))
1437
+ const u = c.get("userId") as string
1438
+ const dir = syncDirFor(u, "repos", c.req.param("name"))
1428
1439
  if (!dir) return c.json({ error: "repo not found" }, 404)
1429
- const r = await pullRepoFromRemote(dir, c.get("userId") as string)
1440
+ const r = await pullRepoFromRemote(dir, u)
1430
1441
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1431
1442
  })
1432
1443
 
@@ -24,6 +24,10 @@ import {
24
24
  workspaceOriginsDir,
25
25
  workspaceOriginPath,
26
26
  personalDir,
27
+ personalKnowledgeDir,
28
+ personalNotesDir,
29
+ personalReposDir,
30
+ personalRepoDir,
27
31
  personalVaultDir,
28
32
  uiNotesDir,
29
33
  personalMemoryDir,
@@ -36,7 +40,7 @@ import {
36
40
  } from "./paths"
37
41
  import type { RepoSpec } from "./config"
38
42
  import { existsSync as existsSyncBase } from "node:fs"
39
- import { loadConfig, loadPersonalConfig } from "./config"
43
+ import { loadConfig, loadPersonalConfig, loadKnowledgeConfig } from "./config"
40
44
  import { ensurePersonalKeypair } from "./personal-keys"
41
45
  import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
42
46
  import { getProvider } from "./git-host"
@@ -335,28 +339,48 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
335
339
  export async function ensureUserContext(user: string, vault: string = "default"): Promise<void> {
336
340
  const cfg = await loadPersonalConfig(user, vault)
337
341
  const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
338
- const layers: Array<[string, string | undefined]> = [
339
- [workspaceKnowledgeDir(), cfg.knowledge?.git],
340
- [workspaceNotesDir(), cfg.notes?.git],
341
- ]
342
- for (const [dir, url] of layers) {
343
- if (!url) continue // personal didn't declare one → keep the host/local origin
344
- if (!existsSyncBase(join(dir, ".git"))) continue // not initialized yet (startup hasn't run)
345
- const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
346
- await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
347
- // Sandbox-side promote: persist core.sshCommand pointing at the vault key's
348
- // SANDBOX path (/loopat/home/<user>/.ssh/id, where the vault home-mount
349
- // lands) with accept-new, so the AI's `git push` inside the sandbox
350
- // authenticates as the user without an interactive host-key prompt. Host-
351
- // side ops override this with GIT_SSH_COMMAND (env beats config), so the
352
- // sandbox path is never used server-side.
353
- const sandboxKey = `/loopat/home/${user}/.ssh/id`
342
+ // Sandbox-side promote: core.sshCommand points at the vault key's SANDBOX path
343
+ // (/loopat/home/<user>/.ssh/id, where the vault home-mount lands) with
344
+ // accept-new, so the AI's `git push` inside the sandbox authenticates as the
345
+ // user without an interactive host-key prompt. Host-side ops override this
346
+ // with GIT_SSH_COMMAND (env beats config), so the sandbox path is never used
347
+ // server-side.
348
+ const sandboxKey = `/loopat/home/${user}/.ssh/id`
349
+ // Clone-or-sync a PER-USER context main repo from `url` with the vault key.
350
+ // STRICT, per the context model: personal wins even when empty an empty url
351
+ // means the dir is REMOVED so the loop sees nothing (no fallback to any
352
+ // workspace default). Returns whether the repo exists afterwards.
353
+ const ensurePerUserRepo = async (dir: string, url: string | undefined): Promise<boolean> => {
354
+ if (!url) {
355
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
356
+ return false
357
+ }
358
+ if (existsSyncBase(join(dir, ".git"))) {
359
+ const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
360
+ await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
361
+ } else {
362
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
363
+ await mkdir(join(dir, ".."), { recursive: true })
364
+ try {
365
+ await execFileP("git", ["clone", "--", url, dir], { env: sshEnv, timeout: 60_000 })
366
+ console.log(`[loopat] cloned per-user context ${url} → ${dir}`)
367
+ } catch (e: any) {
368
+ console.warn(`[loopat] per-user context clone failed (${url}): ${e?.stderr ?? e?.message ?? e}`)
369
+ return false
370
+ }
371
+ }
354
372
  await execFileP("git", ["-C", dir, "config", "core.sshCommand",
355
373
  `ssh -i ${sandboxKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`]).catch(() => {})
356
- // validate connectivity + populate origin/* with the user's key (transient,
357
- // server-side only).
358
- await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 20_000 }).catch(() => {})
374
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {})
375
+ return true
359
376
  }
377
+ // knowledge is the entry pointer (personal-declared url); clone it first, then
378
+ // read ITS .loopat/config.json for the notes remote + repo roster — notes and
379
+ // repos now live inside the per-user knowledge repo, not in personal config.
380
+ const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git)
381
+ const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined, repos: [] as RepoSpec[] }
382
+ await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git)
383
+ await writeReposManifest(personalReposDir(user), kcfg.repos ?? [])
360
384
  }
361
385
 
362
386
  /**
@@ -365,8 +389,8 @@ export async function ensureUserContext(user: string, vault: string = "default")
365
389
  * clone a repo only when it's actually needed. Per docs/context-flow.md the AI
366
390
  * can also clone any listed repo by hand into context/repos/<name>.
367
391
  */
368
- async function writeReposManifest(specs: RepoSpec[]) {
369
- await mkdir(workspaceReposDir(), { recursive: true })
392
+ async function writeReposManifest(reposDir: string, specs: RepoSpec[]) {
393
+ await mkdir(reposDir, { recursive: true })
370
394
  const body = [
371
395
  "# repos — clone on demand",
372
396
  "",
@@ -376,21 +400,22 @@ async function writeReposManifest(specs: RepoSpec[]) {
376
400
  ...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
377
401
  "",
378
402
  ].join("\n")
379
- await writeFile(join(workspaceReposDir(), "REPOS.md"), body)
403
+ await writeFile(join(reposDir, "REPOS.md"), body)
380
404
  }
381
405
 
382
406
  /**
383
407
  * Clone a single registered repo if it isn't present yet. Returns whether the
384
408
  * repo dir exists afterwards. Used by loop creation and any on-demand path.
385
409
  */
386
- async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
387
- const dir = workspaceRepoDir(name)
410
+ async function ensureRepoCloned(user: string, name: string, sshCommand?: string): Promise<boolean> {
411
+ const dir = personalRepoDir(user, name)
388
412
  if (existsSyncBase(dir)) return true
389
- const cfg = await loadConfig()
390
- const spec = cfg.repos?.find((r) => r.name === name)
413
+ // The roster lives in the user's OWN knowledge repo (per-user, no fallback).
414
+ const kcfg = await loadKnowledgeConfig(user)
415
+ const spec = kcfg.repos?.find((r) => r.name === name)
391
416
  if (!spec?.git) return false
392
417
  try {
393
- await mkdir(workspaceReposDir(), { recursive: true })
418
+ await mkdir(personalReposDir(user), { recursive: true })
394
419
  const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
395
420
  await execFileP("git", ["clone", "--", spec.git, dir], { env })
396
421
  console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
@@ -406,11 +431,14 @@ export async function ensureWorkspaceDirs() {
406
431
  await mkdir(loopsDir(), { recursive: true })
407
432
  await mkdir(workspaceReposDir(), { recursive: true })
408
433
 
409
- // knowledge / notes / repos: clone from config'd remote if present
434
+ // WORKSPACE-DEFAULT clone (bootstrap display + seed source only loops use the
435
+ // per-user knowledge/notes, see ensureUserContext). knowledge is the entry
436
+ // pointer; clone it, then read its .loopat/config.json for notes + repo roster.
410
437
  const cfg = await loadConfig()
411
438
  await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
412
- await ensureContextRepo(workspaceNotesDir(), "notes", cfg.notes?.git || undefined)
413
- await writeReposManifest(cfg.repos ?? [])
439
+ const kcfg = await loadKnowledgeConfig()
440
+ await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined)
441
+ await writeReposManifest(workspaceReposDir(), kcfg.repos ?? [])
414
442
 
415
443
  // workspace memory dir + stub
416
444
  const tm = workspaceMemoryDir()
@@ -1327,7 +1355,10 @@ export async function pushPersonalToRemote(
1327
1355
  * rebuilt from origin if missing.
1328
1356
  */
1329
1357
  export async function ensureUiNotesWorktree(user: string): Promise<void> {
1330
- await ensureContextWorktree(workspaceNotesDir(), uiNotesDir(user), `ui/${user}`)
1358
+ // Ensure the user's per-user notes main repo is cloned from their declared
1359
+ // remote first (notes is per-user now), then open the UI worktree from it.
1360
+ await ensureUserContext(user).catch(() => {})
1361
+ await ensurePerUserContextWorktree(personalNotesDir(user), uiNotesDir(user), `ui/${user}`)
1331
1362
  }
1332
1363
 
1333
1364
  /**
@@ -1803,15 +1834,34 @@ async function remoteStartPoint(repo: string, sshCommand?: string): Promise<stri
1803
1834
  }
1804
1835
  }
1805
1836
 
1837
+ /**
1838
+ * Worktree from a PER-USER context main repo. When the main repo is absent (the
1839
+ * user declared no remote for this context — see ensureUserContext's strict
1840
+ * rule), the loop gets an EMPTY dir, never a fallback to a workspace default.
1841
+ */
1842
+ async function ensurePerUserContextWorktree(repo: string, path: string, branch: string) {
1843
+ if (!existsSyncBase(join(repo, ".git"))) {
1844
+ try { await rm(path, { recursive: true, force: true }) } catch {}
1845
+ await mkdir(path, { recursive: true })
1846
+ return
1847
+ }
1848
+ await ensureContextWorktree(repo, path, branch)
1849
+ }
1850
+
1806
1851
  export async function ensureContextMounts(id: string, createdBy: string) {
1807
1852
  await mkdir(loopContextDir(id), { recursive: true })
1808
- await ensureContextWorktree(workspaceKnowledgeDir(), loopContextKnowledge(id), `loop/${id}`)
1809
- await ensureContextWorktree(workspaceNotesDir(), loopContextNotes(id), `loop/${id}`)
1810
- // personal is also a per-loop worktree (docs/context-flow.md) same shape as
1811
- // notes, just wired to the user's private remote. ensureContextWorktree falls
1812
- // back to a symlink when personal/ isn't a git repo yet.
1853
+ // knowledge / notes are per-user (cloned by ensureUserContext from the user's
1854
+ // personal-declared remotes). Each worktree opens from origin/main — a fresh
1855
+ // pull of consensus; the local main repo is just the fetch cache + worktree
1856
+ // host (docs/context-flow.md). Empty when the user declared no remote.
1857
+ await ensurePerUserContextWorktree(personalKnowledgeDir(createdBy), loopContextKnowledge(id), `loop/${id}`)
1858
+ await ensurePerUserContextWorktree(personalNotesDir(createdBy), loopContextNotes(id), `loop/${id}`)
1859
+ // personal is also a per-loop worktree — same shape, wired to the user's
1860
+ // private remote. ensureContextWorktree falls back to a symlink when
1861
+ // personal/ isn't a git repo yet.
1813
1862
  await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
1814
- await ensureSymlink(loopContextRepos(id), workspaceReposDir())
1863
+ await mkdir(personalReposDir(createdBy), { recursive: true })
1864
+ await ensureSymlink(loopContextRepos(id), personalReposDir(createdBy))
1815
1865
  }
1816
1866
 
1817
1867
  export async function listLoops(): Promise<LoopMeta[]> {
@@ -1886,15 +1936,22 @@ export async function createLoop(opts: {
1886
1936
  await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
1887
1937
  await writeLoopSettings(id)
1888
1938
 
1939
+ // Pull the per-user knowledge/notes FIRST: clones the user's knowledge repo
1940
+ // (from personal.knowledge) and reads its .loopat/config.json for the notes
1941
+ // remote + repo roster — which the workdir clone-on-demand below depends on.
1942
+ await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
1943
+ (e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
1944
+ )
1945
+
1889
1946
  // workdir = git worktree add (if repo selected) OR plain mkdir
1890
1947
  if (opts.repo) {
1891
1948
  // clone + fetch as the user (their vault key), not the host's ssh.
1892
1949
  const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
1893
1950
  // clone-on-demand: pull the repo down only now that a loop actually needs it
1894
- if (!(await ensureRepoCloned(opts.repo, userSsh))) {
1951
+ if (!(await ensureRepoCloned(opts.createdBy, opts.repo, userSsh))) {
1895
1952
  throw new Error(`repo "${opts.repo}" not found / clone failed`)
1896
1953
  }
1897
- const repoPath = workspaceRepoDir(opts.repo)
1954
+ const repoPath = personalRepoDir(opts.createdBy, opts.repo)
1898
1955
  const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
1899
1956
  try {
1900
1957
  // ① pull (docs/context-flow.md): base the workdir branch on origin/main
@@ -1915,11 +1972,7 @@ export async function createLoop(opts: {
1915
1972
  await mkdir(loopWorkdir(id), { recursive: true })
1916
1973
  }
1917
1974
 
1918
- // Point the context repos at the personal-declared kn/notes remotes and
1919
- // connect with the user's vault key (personal repo is self-describing).
1920
- await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
1921
- (e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
1922
- )
1975
+ // (ensureUserContext already ran above, before the workdir clone.)
1923
1976
  await ensureContextMounts(id, effectiveDriver(meta))
1924
1977
  await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
1925
1978
  return meta
@@ -35,6 +35,21 @@ export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(),
35
35
  export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers")
36
36
  export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user)
37
37
 
38
+ // Per-user context main repos. knowledge/notes/repos are NOT workspace-shared:
39
+ // each user's loop sees ONLY what their personal.knowledge points at (no
40
+ // fallback to the workspace default). These are the main repos the loop's
41
+ // context worktrees are derived from — the per-user analogue of the shared
42
+ // workspaceKnowledgeDir()/workspaceNotesDir(). Live under context/users/<user>/
43
+ // so they never collide with the workspace-default context/knowledge.
44
+ export const userContextDir = (user: string) => join(workspaceContextDir(), "users", user)
45
+ export const personalKnowledgeDir = (user: string) => join(userContextDir(user), "knowledge")
46
+ export const personalNotesDir = (user: string) => join(userContextDir(user), "notes")
47
+ export const personalReposDir = (user: string) => join(userContextDir(user), "repos")
48
+ export const personalRepoDir = (user: string, name: string) => join(personalReposDir(user), name)
49
+ // The per-user knowledge repo's .loopat root (holds its config.json = notes +
50
+ // repo roster). Workspace-default equivalent is workspaceLoopatRoot().
51
+ export const personalKnowledgeLoopatRoot = (user: string) => join(personalKnowledgeDir(user), ".loopat")
52
+
38
53
  export const loopDir = (id: string) => join(loopsDir(), id)
39
54
  export const loopWorkdir = (id: string) => join(loopDir(id), "workdir")
40
55
  export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude")
@@ -46,12 +46,12 @@ import {
46
46
  loopClaudeDir,
47
47
  loopsDir,
48
48
  loopContextChatDir,
49
- workspaceKnowledgeDir,
50
- workspaceNotesDir,
51
- workspaceReposDir,
52
49
  loopContextKnowledge,
53
50
  loopContextNotes,
54
51
  personalDir,
52
+ personalKnowledgeDir,
53
+ personalNotesDir,
54
+ personalReposDir,
55
55
  LOOPAT_INSTALL_DIR,
56
56
  loopHomeUpper,
57
57
  workspaceHomeSkelDir,
@@ -250,20 +250,22 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
250
250
  }
251
251
 
252
252
  // Repos: bind at virtual path AND host-absolute path (git worktree internals
253
- // store absolute gitdir paths). Both RW.
254
- const reposDir = workspaceReposDir()
253
+ // store absolute gitdir paths). Both RW. PER-USER (the loop's own roster).
254
+ const reposDir = personalReposDir(createdBy)
255
255
  if (existsSync(reposDir)) {
256
256
  mounts.push({ src: reposDir, dst: V_CONTEXT_REPOS })
257
257
  mounts.push({ src: reposDir, dst: reposDir })
258
258
  }
259
259
 
260
- // notes/knowledge main repos: re-bind at host-absolute path so per-loop
261
- // worktree `.git` files resolve.
262
- const notesRepo = workspaceNotesDir()
260
+ // notes/knowledge main repos: re-bind at host-absolute path so the per-loop
261
+ // worktree `.git` files resolve. PER-USER — the worktrees are derived from
262
+ // personalKnowledgeDir/personalNotesDir(createdBy), so the main repos bound
263
+ // here must match (same as personalDir above).
264
+ const notesRepo = personalNotesDir(createdBy)
263
265
  if (existsSync(notesRepo)) {
264
266
  mounts.push({ src: notesRepo, dst: notesRepo })
265
267
  }
266
- const knowledgeRepo = workspaceKnowledgeDir()
268
+ const knowledgeRepo = personalKnowledgeDir(createdBy)
267
269
  if (existsSync(knowledgeRepo)) {
268
270
  mounts.push({ src: knowledgeRepo, dst: knowledgeRepo, ro: !knowledgeRw })
269
271
  }
@@ -20,7 +20,7 @@ import { readFile } from "node:fs/promises"
20
20
  import { execFile } from "node:child_process"
21
21
  import { promisify } from "node:util"
22
22
  import { effectiveDriver, type LoopMeta } from "./loops"
23
- import { bundledDoctrinePath, workspaceNotesDir, workspaceKnowledgeDir } from "./paths"
23
+ import { bundledDoctrinePath, personalNotesDir, personalKnowledgeDir } from "./paths"
24
24
 
25
25
  const execFileP = promisify(execFile)
26
26
 
@@ -47,9 +47,10 @@ async function detectTrunkBranch(repoDir: string): Promise<string> {
47
47
 
48
48
  async function buildRuntimeBlock(loop: LoopMeta): Promise<string> {
49
49
  const repoLine = loop.repo ? `${loop.repo} (branch ${loop.branch ?? "main"})` : "(no repo bound — empty workdir)"
50
+ const driver = effectiveDriver(loop)
50
51
  const [notesTrunk, knowledgeTrunk] = await Promise.all([
51
- detectTrunkBranch(workspaceNotesDir()),
52
- detectTrunkBranch(workspaceKnowledgeDir()),
52
+ detectTrunkBranch(personalNotesDir(driver)),
53
+ detectTrunkBranch(personalKnowledgeDir(driver)),
53
54
  ])
54
55
  const lines = [
55
56
  `## Runtime context (this loop)`,
@@ -11,10 +11,13 @@ import { promisify } from "node:util"
11
11
  import { homedir } from "node:os"
12
12
  import { join, normalize, relative, resolve as resolvePath, sep, dirname } from "node:path"
13
13
  import {
14
- workspaceKnowledgeDir,
15
- workspaceReposDir,
14
+ personalKnowledgeDir,
15
+ personalReposDir,
16
16
  personalDir,
17
17
  uiNotesDir,
18
+ // workspace-level repos admin (addRepo/listRepos/...) — superseded by the
19
+ // per-user knowledge-config repo roster in batch4b; kept until then.
20
+ workspaceReposDir,
18
21
  } from "./paths"
19
22
 
20
23
  const execFileP = promisify(execFile)
@@ -31,7 +34,7 @@ export type VaultEntry = {
31
34
  export function vaultRoot(vault: VaultId, user: string): string {
32
35
  switch (vault) {
33
36
  case "knowledge":
34
- return workspaceKnowledgeDir()
37
+ return personalKnowledgeDir(user)
35
38
  case "notes":
36
39
  // notes is edited via a per-user UI-loop worktree (opened from origin/main);
37
40
  // the endpoint ensures the worktree exists before this resolves.
@@ -39,7 +42,7 @@ export function vaultRoot(vault: VaultId, user: string): string {
39
42
  case "personal":
40
43
  return personalDir(user)
41
44
  case "repos":
42
- return workspaceReposDir()
45
+ return personalReposDir(user)
43
46
  }
44
47
  }
45
48