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,474 @@
1
+ /**
2
+ * Compose loop's `.claude/` by merging multiple CC-native `.claude/` source
3
+ * dirs (team + active profiles + personal) — post-2026-05 CC-native refactor.
4
+ *
5
+ * Sources (low precedence → high; later wins):
6
+ * 1. team: knowledge/.loopat/.claude/
7
+ * 2. profile: knowledge/.loopat/profiles/<name>/.claude/ (per active profile, in order)
8
+ * 3. personal: personal/<user>/CLAUDE.md (file) + personal/.loopat/claude/* (skills/agents)
9
+ *
10
+ * Merge semantics per file:
11
+ * - settings.json: deep merge; `enabledPlugins` and `extraKnownMarketplaces`
12
+ * are dict unions across sources; other fields take last-wins
13
+ * - CLAUDE.md: ordered concat with source markers
14
+ * - skills/ + agents/: symlink union (entries from later sources shadow same-name)
15
+ *
16
+ * The merged dir at loop/.claude/ is the SDK's CLAUDE_CONFIG_DIR — CC reads
17
+ * CLAUDE.md, skills, agents from there. Plugins are passed to SDK via the
18
+ * `plugins` option (see plugin-installer.ts); cache lookups bypass.
19
+ *
20
+ * Re-run every spawn; idempotent (nuke + remake).
21
+ */
22
+ import { existsSync } from "node:fs"
23
+ import { mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
24
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"
25
+ import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"
26
+ import {
27
+ loopClaudeDir,
28
+ personalAgentsDir,
29
+ personalClaudeDir,
30
+ personalClaudeMdPath,
31
+ personalSettingsPath,
32
+ personalSkillsDir,
33
+ } from "./paths"
34
+ import { resolveLoopPlan, type LoopPlan } from "./profiles"
35
+
36
+ /** Read JSON, return null if missing/malformed. */
37
+ async function readJson<T = unknown>(path: string): Promise<T | null> {
38
+ if (!existsSync(path)) return null
39
+ try {
40
+ return JSON.parse(await readFile(path, "utf8")) as T
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Fill `mergedSettings.mcpServers` with defaults from each enabled plugin's
48
+ * own `settings.json` mcpServers. Plugins are the lowest-priority source:
49
+ * a plugin's server is only added if no higher tier (team/profile/personal)
50
+ * already defines a server with the same name.
51
+ *
52
+ * Plugins shipping `.mcp.json` are NOT read here — loopat treats `.mcp.json`
53
+ * as deprecated; plugin authors should put mcpServers in `settings.json`.
54
+ */
55
+ async function fillPluginMcpDefaults(mergedSettings: Record<string, any>): Promise<void> {
56
+ const enabled = Object.entries(
57
+ (mergedSettings.enabledPlugins ?? {}) as Record<string, boolean>,
58
+ )
59
+ .filter(([_, v]) => v)
60
+ .map(([k]) => k)
61
+ if (enabled.length === 0) return
62
+
63
+ const { lookupPluginInstallPath } = await import("./plugin-installer")
64
+ const existing = { ...((mergedSettings.mcpServers ?? {}) as Record<string, any>) }
65
+ const merged: Record<string, any> = { ...existing }
66
+
67
+ for (const spec of enabled) {
68
+ const pluginDir = await lookupPluginInstallPath(spec)
69
+ if (!pluginDir) continue
70
+ const ps = await readJson<{ mcpServers?: Record<string, any> }>(
71
+ join(pluginDir, "settings.json"),
72
+ )
73
+ const pluginServers = ps?.mcpServers
74
+ if (!pluginServers) continue
75
+ for (const [name, srv] of Object.entries(pluginServers)) {
76
+ if (merged[name] === undefined) {
77
+ merged[name] = srv
78
+ }
79
+ }
80
+ }
81
+
82
+ if (Object.keys(merged).length > 0) {
83
+ mergedSettings.mcpServers = merged
84
+ }
85
+ }
86
+
87
+ /** Read TOML, return null if missing/malformed. */
88
+ async function readToml<T = Record<string, any>>(path: string): Promise<T | null> {
89
+ if (!existsSync(path)) return null
90
+ try {
91
+ return tomlParse(await readFile(path, "utf8")) as T
92
+ } catch (e: any) {
93
+ console.warn(`[compose] toml malformed at ${path}: ${e?.message ?? e}`)
94
+ return null
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Deep-merge TOML-shaped objects (mise.toml / mise.lock semantics):
100
+ * - tables (objects): union by key, recursing one level so [tools.node] merges sensibly
101
+ * - primitives / arrays: last wins
102
+ * Mise's typical tables — [tools], [env], [settings], [hooks], [tasks] — all
103
+ * benefit from key-wise union.
104
+ */
105
+ function mergeToml(
106
+ dst: Record<string, any>,
107
+ src: Record<string, any>,
108
+ ): Record<string, any> {
109
+ const out: Record<string, any> = { ...dst }
110
+ for (const [k, v] of Object.entries(src)) {
111
+ if (v === undefined) continue
112
+ if (
113
+ typeof v === "object" && v !== null && !Array.isArray(v) &&
114
+ typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k])
115
+ ) {
116
+ // For nested tables (e.g. [tools.node] = { version, checksum }), recurse one level
117
+ const merged: Record<string, any> = { ...out[k] }
118
+ for (const [k2, v2] of Object.entries(v)) {
119
+ if (
120
+ typeof v2 === "object" && v2 !== null && !Array.isArray(v2) &&
121
+ typeof merged[k2] === "object" && merged[k2] !== null && !Array.isArray(merged[k2])
122
+ ) {
123
+ merged[k2] = { ...merged[k2], ...v2 }
124
+ } else {
125
+ merged[k2] = v2
126
+ }
127
+ }
128
+ out[k] = merged
129
+ } else {
130
+ out[k] = v
131
+ }
132
+ }
133
+ return out
134
+ }
135
+
136
+ /**
137
+ * Normalize a single extraKnownMarketplaces entry: if its source is
138
+ * `{source: "directory", path: <relative>}`, resolve the path against the
139
+ * settings file's dir so merged loop settings end up with absolute paths.
140
+ * (Loop's merged settings.json is read later by plugin-installer.ts, which
141
+ * doesn't know the original source location.)
142
+ */
143
+ function normalizeMarketplaceEntry(entry: any, settingsFilePath: string): any {
144
+ if (!entry || typeof entry !== "object") return entry
145
+ const src = entry.source
146
+ if (typeof src === "object" && src.source === "directory" && typeof src.path === "string") {
147
+ if (!isAbsolute(src.path)) {
148
+ const abs = resolvePath(dirname(settingsFilePath), src.path)
149
+ return { ...entry, source: { ...src, path: abs } }
150
+ }
151
+ }
152
+ return entry
153
+ }
154
+
155
+ /**
156
+ * Deep-merge a source settings.json into the accumulator. `enabledPlugins` +
157
+ * `extraKnownMarketplaces` union by key; other dict fields shallow-union;
158
+ * primitives = last wins. extraKnownMarketplaces paths normalize to absolute.
159
+ *
160
+ * `srcPath` is the source settings file's host path — needed to resolve
161
+ * relative paths in `extraKnownMarketplaces[*].source.path`.
162
+ */
163
+ function mergeSettings(
164
+ dst: Record<string, any>,
165
+ src: Record<string, any>,
166
+ srcPath: string,
167
+ ): Record<string, any> {
168
+ const out: Record<string, any> = { ...dst }
169
+ for (const [k, v] of Object.entries(src)) {
170
+ if (k === "_comment") continue
171
+ if (v === undefined) continue
172
+ if (k === "extraKnownMarketplaces" && typeof v === "object" && v !== null && !Array.isArray(v)) {
173
+ const normalized: Record<string, any> = {}
174
+ for (const [name, entry] of Object.entries(v)) {
175
+ normalized[name] = normalizeMarketplaceEntry(entry, srcPath)
176
+ }
177
+ out[k] = { ...(out[k] ?? {}), ...normalized }
178
+ } else if (
179
+ k === "enabledPlugins" &&
180
+ typeof v === "object" && v !== null && !Array.isArray(v)
181
+ ) {
182
+ out[k] = { ...(out[k] ?? {}), ...v }
183
+ } else if (
184
+ typeof v === "object" && v !== null && !Array.isArray(v) &&
185
+ typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k])
186
+ ) {
187
+ out[k] = { ...out[k], ...v } // shallow union for other dicts
188
+ } else {
189
+ out[k] = v // primitives / arrays / different types — last wins
190
+ }
191
+ }
192
+ return out
193
+ }
194
+
195
+ /**
196
+ * For each source's `.claude/<subdir>/` (skills or agents), symlink its entries
197
+ * into dst. Later sources shadow earlier (same-name → relink). Missing source
198
+ * dirs silently skipped. Filter restricts to certain file types (e.g. .md for
199
+ * agents). Symlink kind is "dir" for skills (each is a dir), "file" for agents.
200
+ */
201
+ async function composeSubdir(
202
+ dst: string,
203
+ sources: Array<{ source: string; rootDir: string }>,
204
+ opts: { kind: "dir" | "file"; filter?: (name: string) => boolean },
205
+ ): Promise<void> {
206
+ await rm(dst, { recursive: true, force: true })
207
+ await mkdir(dst, { recursive: true })
208
+ for (const src of sources) {
209
+ if (!existsSync(src.rootDir)) continue
210
+ let entries: string[]
211
+ try {
212
+ entries = await readdir(src.rootDir)
213
+ } catch {
214
+ continue
215
+ }
216
+ for (const name of entries) {
217
+ if (name.startsWith(".")) continue
218
+ if (opts.filter && !opts.filter(name)) continue
219
+ const linkPath = join(dst, name)
220
+ await rm(linkPath, { force: true }).catch(() => {})
221
+ await symlink(join(src.rootDir, name), linkPath, opts.kind)
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Resolve where each LoopPlan source has its .claude/-like dirs.
228
+ * For team + profiles, `dir` is the `.claude/` dir itself.
229
+ * For personal (`personal:<u>`), everything lives under `.loopat/.claude/`
230
+ * (mirrors CC's own `~/.claude/` convention but namespaced under `.loopat/`):
231
+ * `CLAUDE.md`, `settings.json`, `skills/`, `agents/`, `mise.toml`, etc.
232
+ */
233
+ type ResolvedSource = {
234
+ source: string
235
+ settings?: string
236
+ claudeMd?: string
237
+ skillsDir?: string
238
+ agentsDir?: string
239
+ miseToml?: string
240
+ miseLock?: string
241
+ /** `.claude/plugins/installed_plugins.json` — CC-native plugin version lock.
242
+ * Same shape as host's. We merge across tiers by spec key (last-wins). */
243
+ installedPlugins?: string
244
+ }
245
+
246
+ function resolveSource(s: { source: string; dir: string }, user: string): ResolvedSource {
247
+ if (s.source.startsWith("personal:")) {
248
+ // Personal layer uses the CC-native `.claude/` shape — same as team / profile.
249
+ return {
250
+ source: s.source,
251
+ settings: personalSettingsPath(user),
252
+ claudeMd: personalClaudeMdPath(user),
253
+ skillsDir: personalSkillsDir(user),
254
+ agentsDir: personalAgentsDir(user),
255
+ miseToml: join(personalClaudeDir(user), "mise.toml"),
256
+ miseLock: join(personalClaudeDir(user), "mise.lock"),
257
+ installedPlugins: join(personalClaudeDir(user), "plugins", "installed_plugins.json"),
258
+ }
259
+ }
260
+ // team / profile / repo — dir IS the .claude/ dir
261
+ return {
262
+ source: s.source,
263
+ settings: join(s.dir, "settings.json"),
264
+ claudeMd: join(s.dir, "CLAUDE.md"),
265
+ skillsDir: join(s.dir, "skills"),
266
+ agentsDir: join(s.dir, "agents"),
267
+ miseToml: join(s.dir, "mise.toml"),
268
+ miseLock: join(s.dir, "mise.lock"),
269
+ installedPlugins: join(s.dir, "plugins", "installed_plugins.json"),
270
+ }
271
+ }
272
+
273
+ export type ComposeResult = {
274
+ claudeMdPath: string
275
+ settingsPath: string
276
+ sources: string[]
277
+ enabledPlugins: string[] // for callers (plugin-installer) to drive install
278
+ extraMarketplaces: string[]
279
+ /** Path to merged mise.toml in loop's .claude/, or null if no source declared toolchain. */
280
+ miseTomlPath: string | null
281
+ /** Path to merged mise.lock in loop's .claude/, or null if no source declared lock. */
282
+ miseLockPath: string | null
283
+ /** Path to merged installed_plugins.json in loop's .claude/plugins/, or null if no tier declared one. */
284
+ installedPluginsPath: string | null
285
+ }
286
+
287
+ /**
288
+ * Compose loop .claude/ from the loop's plan. Runs ONCE at loop creation;
289
+ * the snapshot is then immutable so subsequent admin pushes to knowledge
290
+ * don't change what an existing loop sees (principle 1: loops never change).
291
+ *
292
+ * Workdir is NOT a tier here — it's read by the SDK as project tier directly
293
+ * (settingSources includes 'project'). Compose only merges the user-tier
294
+ * sources: workspace + N profiles + personal.
295
+ *
296
+ * Returns paths for downstream use (plugin-installer reads merged settings;
297
+ * spawn reads CLAUDE_CONFIG_DIR).
298
+ */
299
+ export async function composeLoopClaudeConfig(
300
+ loopId: string,
301
+ user: string,
302
+ profiles?: string[],
303
+ ): Promise<ComposeResult> {
304
+ const plan: LoopPlan = await resolveLoopPlan({
305
+ user,
306
+ overrideProfiles: profiles,
307
+ })
308
+ return composeFromPlan(loopId, plan)
309
+ }
310
+
311
+ export async function composeFromPlan(loopId: string, plan: LoopPlan): Promise<ComposeResult> {
312
+ const dst = loopClaudeDir(loopId)
313
+ await mkdir(dst, { recursive: true })
314
+
315
+ const resolved = plan.claudeSources.map((s) => resolveSource(s, plan.user))
316
+
317
+ // 1. Merge settings.json
318
+ let mergedSettings: Record<string, any> = {}
319
+ for (const r of resolved) {
320
+ if (!r.settings) continue
321
+ const obj = await readJson<Record<string, any>>(r.settings)
322
+ if (obj) mergedSettings = mergeSettings(mergedSettings, obj, r.settings)
323
+ }
324
+
325
+ // 1.5 Fill `mcpServers` defaults from enabled plugins (those plugins' own
326
+ // settings.json mcpServers — never `.mcp.json`, which loopat doesn't read).
327
+ // Plugins are the lowest priority: their entries only fill keys not
328
+ // already defined by team / profile / personal merge above.
329
+ await fillPluginMcpDefaults(mergedSettings)
330
+
331
+ const settingsPath = join(dst, "settings.json")
332
+ // Inject loopat-managed fields that downstream code expects.
333
+ mergedSettings.autoMemoryEnabled = mergedSettings.autoMemoryEnabled ?? true
334
+ mergedSettings.autoMemoryDirectory =
335
+ mergedSettings.autoMemoryDirectory ?? "/loopat/context/personal/memory"
336
+ await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2))
337
+
338
+ // 2. Concat CLAUDE.md
339
+ const claudeMdPath = join(dst, "CLAUDE.md")
340
+ const parts: string[] = []
341
+ for (const r of resolved) {
342
+ if (!r.claudeMd || !existsSync(r.claudeMd)) continue
343
+ try {
344
+ const content = (await readFile(r.claudeMd, "utf8")).trim()
345
+ parts.push(
346
+ `<!-- ========== ${r.source} ========== -->\n<!-- from: ${r.claudeMd} -->\n${content}`,
347
+ )
348
+ } catch (e: any) {
349
+ console.warn(`[compose] ${r.source} CLAUDE.md unreadable: ${e?.message ?? e}`)
350
+ }
351
+ }
352
+ if (parts.length === 0) {
353
+ await rm(claudeMdPath, { force: true })
354
+ } else {
355
+ await writeFile(claudeMdPath, parts.join("\n\n") + "\n")
356
+ }
357
+
358
+ // 3. Symlink-merge skills/ (each entry is a dir with SKILL.md)
359
+ await composeSubdir(
360
+ join(dst, "skills"),
361
+ resolved.filter((r) => r.skillsDir).map((r) => ({ source: r.source, rootDir: r.skillsDir! })),
362
+ { kind: "dir" },
363
+ )
364
+
365
+ // 4. Symlink-merge agents/ (each entry is a single .md file)
366
+ await composeSubdir(
367
+ join(dst, "agents"),
368
+ resolved.filter((r) => r.agentsDir).map((r) => ({ source: r.source, rootDir: r.agentsDir! })),
369
+ { kind: "file", filter: (n) => n.endsWith(".md") },
370
+ )
371
+
372
+ // 5. Merge mise.toml + mise.lock (toolchain layer, loopat-native extension to .claude/)
373
+ let mergedMiseToml: Record<string, any> = {}
374
+ let anyMiseToml = false
375
+ for (const r of resolved) {
376
+ if (!r.miseToml) continue
377
+ const obj = await readToml<Record<string, any>>(r.miseToml)
378
+ if (obj) {
379
+ mergedMiseToml = mergeToml(mergedMiseToml, obj)
380
+ anyMiseToml = true
381
+ }
382
+ }
383
+ let miseTomlPath: string | null = null
384
+ if (anyMiseToml) {
385
+ miseTomlPath = join(dst, "mise.toml")
386
+ await writeFile(miseTomlPath, tomlStringify(mergedMiseToml))
387
+ } else {
388
+ await rm(join(dst, "mise.toml"), { force: true })
389
+ }
390
+
391
+ let mergedMiseLock: Record<string, any> = {}
392
+ let anyMiseLock = false
393
+ for (const r of resolved) {
394
+ if (!r.miseLock) continue
395
+ const obj = await readToml<Record<string, any>>(r.miseLock)
396
+ if (obj) {
397
+ mergedMiseLock = mergeToml(mergedMiseLock, obj)
398
+ anyMiseLock = true
399
+ }
400
+ }
401
+ let miseLockPath: string | null = null
402
+ if (anyMiseLock) {
403
+ miseLockPath = join(dst, "mise.lock")
404
+ await writeFile(miseLockPath, tomlStringify(mergedMiseLock))
405
+ } else {
406
+ await rm(join(dst, "mise.lock"), { force: true })
407
+ }
408
+
409
+ // 6. Merge installed_plugins.json (CC-native plugin version lock).
410
+ //
411
+ // Each tier may publish a .claude/plugins/installed_plugins.json with the
412
+ // same shape CC writes to ~/.claude/plugins/. We union by spec key,
413
+ // last-wins (personal overrides team). The merged file is the loop's lock —
414
+ // bwrap binds it over the sandbox's ~/.claude/plugins/installed_plugins.json
415
+ // so the inner SDK resolves each plugin to the pinned version, not whatever
416
+ // happens to be on the host right now.
417
+ //
418
+ // Why include this at all: without a per-loop snapshot, member's
419
+ // `claude plugin update` on host would silently change what a previously-
420
+ // created loop sees on next spawn. Locking via this file freezes the loop's
421
+ // plugin set at creation time (principle 1).
422
+ let mergedInstalledPlugins: { version?: number; plugins?: Record<string, any[]> } | null = null
423
+ for (const r of resolved) {
424
+ if (!r.installedPlugins) continue
425
+ const obj = await readJson<{ version?: number; plugins?: Record<string, any[]> }>(
426
+ r.installedPlugins,
427
+ )
428
+ if (!obj) continue
429
+ if (!mergedInstalledPlugins) {
430
+ mergedInstalledPlugins = { version: obj.version ?? 1, plugins: {} }
431
+ }
432
+ for (const [spec, entries] of Object.entries(obj.plugins ?? {})) {
433
+ mergedInstalledPlugins.plugins![spec] = entries // per-spec last-wins
434
+ }
435
+ }
436
+ let installedPluginsPath: string | null = null
437
+ if (mergedInstalledPlugins) {
438
+ const ipDir = join(dst, "plugins")
439
+ await mkdir(ipDir, { recursive: true })
440
+ installedPluginsPath = join(ipDir, "installed_plugins.json")
441
+ await writeFile(installedPluginsPath, JSON.stringify(mergedInstalledPlugins, null, 2))
442
+ } else {
443
+ // No tier declared a lock → ensure no stale lock from a previous compose
444
+ await rm(join(dst, "plugins", "installed_plugins.json"), { force: true })
445
+ }
446
+
447
+ const enabledPlugins = Object.keys(
448
+ (mergedSettings.enabledPlugins ?? {}) as Record<string, boolean>,
449
+ ).filter((k) => mergedSettings.enabledPlugins[k])
450
+
451
+ const extraMarketplaces = Object.keys(
452
+ (mergedSettings.extraKnownMarketplaces ?? {}) as Record<string, unknown>,
453
+ )
454
+
455
+ return {
456
+ claudeMdPath,
457
+ settingsPath,
458
+ sources: resolved.map((r) => r.source),
459
+ enabledPlugins,
460
+ extraMarketplaces,
461
+ miseTomlPath,
462
+ miseLockPath,
463
+ installedPluginsPath,
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Write settings.json under the loop's .claude/. DEPRECATED in the CC-native
469
+ * model — settings are written by composeFromPlan. Kept as a no-op for
470
+ * backward-compat callers (loops.ts). Use composeLoopClaudeConfig instead.
471
+ */
472
+ export async function writeLoopSettings(_loopId: string): Promise<void> {
473
+ // no-op
474
+ }