shraga 0.1.13 → 0.1.15

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 (55) hide show
  1. package/defaults/modules/routine/README.md +38 -0
  2. package/defaults/modules/routine/module.json +36 -0
  3. package/defaults/modules/routine/routine.md.tmpl +75 -0
  4. package/defaults/modules/routine/seeds/workspace/agenda.md +15 -0
  5. package/defaults/skills/add-skill.md +4 -0
  6. package/defaults/skills/artifacts.md +4 -0
  7. package/defaults/skills/communications.md +4 -0
  8. package/defaults/skills/create-module.md +75 -0
  9. package/defaults/skills/debug.md +4 -0
  10. package/defaults/skills/garden.md +4 -0
  11. package/defaults/skills/github-contributor.md +4 -0
  12. package/defaults/skills/identity.md +4 -0
  13. package/defaults/skills/mcp-server.md +4 -0
  14. package/defaults/skills/modules.md +41 -0
  15. package/defaults/skills/plan.md +4 -0
  16. package/defaults/skills/reconcile.md +4 -0
  17. package/defaults/skills/scheduler.md +5 -2
  18. package/defaults/skills/self-aware.md +5 -1
  19. package/defaults/skills/write-tests.md +4 -0
  20. package/dist/client/assets/index-D5KxKl57.js +1946 -0
  21. package/dist/client/assets/index-nyZagTjP.css +10 -0
  22. package/dist/client/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/client/App.tsx +11 -0
  25. package/src/client/components/ChatView.tsx +14 -0
  26. package/src/client/components/ConversationHeader.tsx +29 -0
  27. package/src/client/components/ModulesManager.tsx +259 -0
  28. package/src/client/hooks/useConversation.ts +5 -3
  29. package/src/client/hooks/useModules.ts +81 -0
  30. package/src/client/lib/api.ts +13 -0
  31. package/src/client/lib/sessionApi.ts +16 -2
  32. package/src/client/lib/workspaceContext.tsx +2 -0
  33. package/src/mcp-stdio-bridge.ts +5 -5
  34. package/src/server/boot.ts +95 -16
  35. package/src/server/claude.ts +12 -1
  36. package/src/server/data-sync.ts +28 -0
  37. package/src/server/engine/claude-code.ts +10 -3
  38. package/src/server/events/types.ts +3 -0
  39. package/src/server/mcp-sidecar.ts +2 -2
  40. package/src/server/mcp.ts +3 -5
  41. package/src/server/modules/index.ts +3 -0
  42. package/src/server/modules/routes.ts +78 -0
  43. package/src/server/modules/service.ts +575 -0
  44. package/src/server/modules/types.ts +62 -0
  45. package/src/server/paths.ts +57 -6
  46. package/src/server/scheduler/builtins.ts +27 -3
  47. package/src/server/scheduler/engine.ts +6 -0
  48. package/src/server/scheduler/runner.ts +96 -30
  49. package/src/server/scheduler/types.ts +3 -0
  50. package/src/server/sessions.ts +4 -0
  51. package/src/server/shraga-config.ts +21 -4
  52. package/src/server/skills.ts +6 -5
  53. package/src/server/slack/bot.ts +3 -1
  54. package/dist/client/assets/index-ChElotX8.js +0 -1936
  55. package/dist/client/assets/index-DdibEb2O.css +0 -10
@@ -0,0 +1,575 @@
1
+ /** Data-plane module service — installs/reconciles declarative module folders into data/.
2
+ *
3
+ * A module folder: module.json + skill templates (*.md / *.md.tmpl) + seeds/<data-relative-path>.
4
+ * Applied source lives at data/modules/<name>/ (origin-agnostic); installed records in
5
+ * data/modules/state.json (atomic tmp+rename).
6
+ *
7
+ * Invariants:
8
+ * - Module schedules use createdBy.uid `module:<name>` — NEVER the system uid: scheduler/builtins.ts
9
+ * purges system-scope schedules with the system uid not in the builtins list on every boot.
10
+ * - Seeds are memory, not status: create-if-missing, never overwritten, survive uninstall.
11
+ * Only state.json answers "is the module on".
12
+ * - Reconcile is idempotent: write-if-changed skills, upserts preserve enabled/runCount/lastRun.
13
+ */
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, rmSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { dataPath, DATA_DIR, PACKAGE_ROOT } from '../paths.ts';
17
+ import { dataSync } from '../data-sync.ts';
18
+ import * as scheduler from '../scheduler/index.ts';
19
+ import type { Schedule } from '../scheduler/types.ts';
20
+ import { parseSkillFrontmatter, getDefaultSkills, setDefaultSkills } from '../skills.ts';
21
+ import type { ModuleManifest, InstalledModule, ModulesState, ModuleScheduleDef } from './types.ts';
22
+
23
+ const MODULES_DIR = () => dataPath('modules');
24
+ const STATE_FILE = () => dataPath('modules/state.json');
25
+ const BUILTIN_MODULES_DIR = path.join(PACKAGE_ROOT, 'defaults', 'modules');
26
+
27
+ // ── State ───────────────────────────────────────────────────────────────────
28
+
29
+ export function loadState(): ModulesState {
30
+ const file = STATE_FILE();
31
+ if (!existsSync(file)) return { installed: [] };
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
34
+ return parsed && Array.isArray(parsed.installed) ? parsed : { installed: [] };
35
+ } catch (err) {
36
+ console.error('[modules] failed to parse state.json:', err);
37
+ return { installed: [] };
38
+ }
39
+ }
40
+
41
+ function saveState(state: ModulesState): void {
42
+ mkdirSync(MODULES_DIR(), { recursive: true });
43
+ const tmp = `${STATE_FILE()}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
45
+ renameSync(tmp, STATE_FILE());
46
+ dataSync.trackWrite('modules/state.json');
47
+ }
48
+
49
+ export function getInstalled(name: string): InstalledModule | undefined {
50
+ return loadState().installed.find((m) => m.name === name);
51
+ }
52
+
53
+ // ── Manifest ────────────────────────────────────────────────────────────────
54
+
55
+ export function readManifest(folder: string): ModuleManifest {
56
+ const file = path.join(folder, 'module.json');
57
+ if (!existsSync(file)) throw new Error(`No module.json in ${folder}`);
58
+ const m = JSON.parse(readFileSync(file, 'utf-8')) as ModuleManifest;
59
+ if (!m.name || !/^[\w-]+$/.test(m.name)) throw new Error('Manifest: invalid or missing "name"');
60
+ if (!m.version) throw new Error('Manifest: missing "version"');
61
+ for (const def of m.schedules ?? []) {
62
+ if (!def.def || !/^[\w-]+$/.test(def.def)) throw new Error(`Manifest: schedule def key invalid: "${def.def}"`);
63
+ if (!def.name || !def.trigger || !def.task) throw new Error(`Manifest: schedule "${def.def}" needs name/trigger/task`);
64
+ }
65
+ for (const s of m.skills ?? []) {
66
+ if (!/\.md(\.tmpl)?$/.test(s)) throw new Error(`Manifest: skill file must be .md or .md.tmpl: "${s}"`);
67
+ }
68
+ for (const seed of m.seeds ?? []) {
69
+ if (path.isAbsolute(seed) || seed.split(/[\\/]/).includes('..')) throw new Error(`Manifest: seed path must be data-relative: "${seed}"`);
70
+ }
71
+ return m;
72
+ }
73
+
74
+ function moduleDir(name: string): string {
75
+ return dataPath('modules', name);
76
+ }
77
+
78
+ /** Adoption backups are USER data (their original artifacts) — kept OUTSIDE the module
79
+ * folder so reinstall/upgrade (copyDir rm+recreate) and uninstall (rmSync) never touch them. */
80
+ function backupDir(name: string): string {
81
+ return dataPath('modules', '.backups', name);
82
+ }
83
+
84
+ function installedManifest(name: string): ModuleManifest {
85
+ return readManifest(moduleDir(name));
86
+ }
87
+
88
+ /** README.md content of a module folder (installed or builtin), capped at 32KB. */
89
+ export function readModuleReadme(name: string, from: 'installed' | 'builtin'): string | undefined {
90
+ if (!/^[\w-]+$/.test(name)) return undefined; // defense-in-depth: never path-join a raw name
91
+ const file = path.join(from === 'builtin' ? path.join(BUILTIN_MODULES_DIR, name) : moduleDir(name), 'README.md');
92
+ if (!existsSync(file)) return undefined;
93
+ try { return readFileSync(file, 'utf-8').slice(0, 32 * 1024); }
94
+ catch (err) { console.warn(`[modules] readme read failed for ${name}:`, (err as Error).message); return undefined; }
95
+ }
96
+
97
+ /** Built-ins shipped in defaults/modules/<name>/ — "available" = readdir. */
98
+ export function listAvailableModules(): ModuleManifest[] {
99
+ if (!existsSync(BUILTIN_MODULES_DIR)) return [];
100
+ const out: ModuleManifest[] = [];
101
+ for (const entry of readdirSync(BUILTIN_MODULES_DIR, { withFileTypes: true })) {
102
+ if (!entry.isDirectory()) continue;
103
+ try { out.push(readManifest(path.join(BUILTIN_MODULES_DIR, entry.name))); }
104
+ catch (err) { console.warn(`[modules] skipping builtin ${entry.name}:`, (err as Error).message); }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // ── Templating ──────────────────────────────────────────────────────────────
110
+
111
+ /** `{{key}}` → config[key]. Unknown keys warn and are left literal. Strings only. */
112
+ export function renderTemplate(tpl: string, config: Record<string, unknown>, ctx = ''): string {
113
+ return tpl.replace(/\{\{(\w+)\}\}/g, (whole, key: string) => {
114
+ if (key in config) return String(config[key]);
115
+ console.warn(`[modules] unknown template key {{${key}}}${ctx ? ` in ${ctx}` : ''}`);
116
+ return whole;
117
+ });
118
+ }
119
+
120
+ /** Deep-walk any JSON value, rendering string leaves. */
121
+ export function renderDeep<T>(value: T, config: Record<string, unknown>, ctx = ''): T {
122
+ if (typeof value === 'string') return renderTemplate(value, config, ctx) as T;
123
+ if (Array.isArray(value)) return value.map((v) => renderDeep(v, config, ctx)) as T;
124
+ if (value && typeof value === 'object') {
125
+ const out: Record<string, unknown> = {};
126
+ for (const [k, v] of Object.entries(value)) out[k] = renderDeep(v, config, ctx);
127
+ return out as T;
128
+ }
129
+ return value;
130
+ }
131
+
132
+ export function effectiveConfig(manifest: ModuleManifest, stored: Record<string, unknown> = {}): Record<string, string | number | boolean> {
133
+ const out: Record<string, string | number | boolean> = {};
134
+ for (const [key, field] of Object.entries(manifest.configSchema ?? {})) {
135
+ if (field.default !== undefined) out[key] = field.default;
136
+ }
137
+ for (const [key, val] of Object.entries(stored)) {
138
+ if (val !== undefined && val !== null) out[key] = val as string | number | boolean;
139
+ }
140
+ return out;
141
+ }
142
+
143
+ // ── Skills ──────────────────────────────────────────────────────────────────
144
+
145
+ function skillNameFor(file: string): string {
146
+ return path.basename(file).replace(/\.tmpl$/, '').replace(/\.md$/, '');
147
+ }
148
+
149
+ /** Render a skill template and stamp `managed-by: <module>@<ver>` into its frontmatter. */
150
+ function renderSkill(mod: ModuleManifest, file: string, config: Record<string, unknown>): { name: string; content: string } {
151
+ const raw = readFileSync(path.join(moduleDir(mod.name), file), 'utf-8');
152
+ const rendered = renderTemplate(raw, config, `${mod.name}/${file}`);
153
+ const marker = `managed-by: ${mod.name}@${mod.version}`;
154
+ let content: string;
155
+ if (rendered.startsWith('---')) {
156
+ content = rendered.replace('---', `---\n${marker}`);
157
+ } else {
158
+ content = `---\n${marker}\n---\n\n${rendered}`;
159
+ }
160
+ return { name: skillNameFor(file), content };
161
+ }
162
+
163
+ function skillPath(name: string): string {
164
+ return dataPath('skills', `${name}.md`);
165
+ }
166
+
167
+ /** True when the on-disk skill of this name is owned by `moduleName` (or absent). */
168
+ function skillOwnedBy(name: string, moduleName: string): boolean {
169
+ const file = skillPath(name);
170
+ if (!existsSync(file)) return true;
171
+ const { meta } = parseSkillFrontmatter(readFileSync(file, 'utf-8'));
172
+ return (meta.managedBy ?? '').split('@')[0] === moduleName;
173
+ }
174
+
175
+ // ── Schedules ───────────────────────────────────────────────────────────────
176
+
177
+ function scheduleIdFor(rec: InstalledModule, def: ModuleScheduleDef): string {
178
+ return rec.adoptedScheduleIds?.[def.def] ?? `mod-${rec.name}-${def.def}`;
179
+ }
180
+
181
+ function managedSchedules(name: string): Schedule[] {
182
+ return scheduler.listSchedules().filter((s) => s.managedBy === name);
183
+ }
184
+
185
+ function globToRegex(glob: string): RegExp {
186
+ return new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
187
+ }
188
+
189
+ /** Unmanaged schedules the module's doctrine caused the agent to create (name glob match). */
190
+ function offspringSchedules(manifest: ModuleManifest): Schedule[] {
191
+ const glob = manifest.offspring?.schedules;
192
+ if (!glob) return [];
193
+ const re = globToRegex(glob);
194
+ return scheduler.listSchedules().filter((s) => !s.managedBy && re.test(s.name));
195
+ }
196
+
197
+ // ── Seeds & dormancy ────────────────────────────────────────────────────────
198
+
199
+ function dormancyLine(moduleName: string): string {
200
+ return `> [${moduleName} disabled ${new Date().toISOString().slice(0, 10)} — retained as state; no proactive cadence active]`;
201
+ }
202
+
203
+ const dormancyPrefix = (moduleName: string) => `> [${moduleName} disabled `;
204
+
205
+ /** Stamp a dormancy header on declared seeds — idempotent (never double-stamps). */
206
+ function stampSeeds(manifest: ModuleManifest): void {
207
+ for (const seed of manifest.seeds ?? []) {
208
+ const file = dataPath(seed);
209
+ if (!existsSync(file)) continue;
210
+ const content = readFileSync(file, 'utf-8');
211
+ if (content.startsWith(dormancyPrefix(manifest.name))) continue;
212
+ writeFileSync(file, `${dormancyLine(manifest.name)}\n\n${content}`);
213
+ dataSync.trackWrite(seed);
214
+ }
215
+ }
216
+
217
+ function unstampSeeds(manifest: ModuleManifest): void {
218
+ for (const seed of manifest.seeds ?? []) {
219
+ const file = dataPath(seed);
220
+ if (!existsSync(file)) continue;
221
+ const content = readFileSync(file, 'utf-8');
222
+ if (!content.startsWith(dormancyPrefix(manifest.name))) continue;
223
+ const nl = content.indexOf('\n');
224
+ writeFileSync(file, content.slice(nl + 1).replace(/^\n+/, ''));
225
+ dataSync.trackWrite(seed);
226
+ }
227
+ }
228
+
229
+ /** Seeds are state: create-if-missing (rendered once), never overwritten. */
230
+ function applySeeds(manifest: ModuleManifest, config: Record<string, unknown>): void {
231
+ for (const seed of manifest.seeds ?? []) {
232
+ const dest = dataPath(seed);
233
+ if (existsSync(dest)) continue;
234
+ const src = path.join(moduleDir(manifest.name), 'seeds', seed);
235
+ if (!existsSync(src)) { console.warn(`[modules] ${manifest.name}: seed source missing: seeds/${seed}`); continue; }
236
+ mkdirSync(path.dirname(dest), { recursive: true });
237
+ writeFileSync(dest, renderTemplate(readFileSync(src, 'utf-8'), config, `${manifest.name}/seeds/${seed}`));
238
+ dataSync.trackWrite(seed);
239
+ }
240
+ }
241
+
242
+ // ── Journal ─────────────────────────────────────────────────────────────────
243
+
244
+ /** One lifecycle line into the workspace journal so the agent's self-knowledge matches reality. */
245
+ function journal(line: string): void {
246
+ try {
247
+ const file = dataPath('workspace', 'journal.md');
248
+ mkdirSync(path.dirname(file), { recursive: true });
249
+ appendFileSync(file, `- ${new Date().toISOString()} ${line}\n`);
250
+ dataSync.trackWrite('workspace/journal.md');
251
+ } catch (err) {
252
+ console.warn('[modules] journal append failed:', (err as Error).message);
253
+ }
254
+ }
255
+
256
+ // ── skills-defaults ─────────────────────────────────────────────────────────
257
+
258
+ /** Add on install/enable only — reconcile never re-adds (a user's removal sticks). */
259
+ function addDefaultSkills(manifest: ModuleManifest): void {
260
+ const names = manifest.defaultSkills ?? [];
261
+ if (!names.length) return;
262
+ const existing = getDefaultSkills();
263
+ const have = new Set(existing.map((e) => (typeof e === 'string' ? e : e.name)));
264
+ const added = names.filter((n) => !have.has(n));
265
+ if (added.length) setDefaultSkills([...existing, ...added]);
266
+ }
267
+
268
+ function removeDefaultSkills(manifest: ModuleManifest): void {
269
+ const names = new Set(manifest.defaultSkills ?? []);
270
+ if (!names.size) return;
271
+ const existing = getDefaultSkills();
272
+ const kept = existing.filter((e) => !names.has(typeof e === 'string' ? e : e.name));
273
+ if (kept.length !== existing.length) setDefaultSkills(kept);
274
+ }
275
+
276
+ // ── Reconcile ───────────────────────────────────────────────────────────────
277
+
278
+ /** Apply one enabled module's folder into data/ — idempotent. */
279
+ function applyModule(rec: InstalledModule): void {
280
+ const manifest = installedManifest(rec.name);
281
+ const config = effectiveConfig(manifest, rec.config);
282
+
283
+ // Skills: render → write-if-changed
284
+ for (const file of manifest.skills ?? []) {
285
+ const { name, content } = renderSkill(manifest, file, config);
286
+ if (!skillOwnedBy(name, rec.name)) {
287
+ console.warn(`[modules] ${rec.name}: skill "${name}" is owned elsewhere — not overwriting`);
288
+ continue;
289
+ }
290
+ const dest = skillPath(name);
291
+ if (existsSync(dest) && readFileSync(dest, 'utf-8') === content) continue;
292
+ mkdirSync(path.dirname(dest), { recursive: true });
293
+ writeFileSync(dest, content);
294
+ dataSync.trackWrite(`skills/${name}.md`);
295
+ }
296
+
297
+ // Schedules: upsert preserving runtime state; module uid — NOT the system uid (builtins purge trap)
298
+ const expectedIds = new Set<string>();
299
+ for (const def of manifest.schedules ?? []) {
300
+ const id = scheduleIdFor(rec, def);
301
+ expectedIds.add(id);
302
+ const rendered = renderDeep({ name: def.name, trigger: def.trigger, task: def.task }, config, `${rec.name}/schedules/${def.def}`);
303
+ // A templated model knob rendered to "" must not ship a bogus empty model — omit the field.
304
+ if ('model' in rendered.task && !(rendered.task as { model?: string }).model) delete (rendered.task as { model?: string }).model;
305
+ const existing = scheduler.getSchedule(id);
306
+ const now = Date.now();
307
+ const schedule: Schedule = {
308
+ id,
309
+ name: rendered.name,
310
+ enabled: existing ? existing.enabled : (def.enabled ?? true),
311
+ trigger: rendered.trigger,
312
+ task: rendered.task,
313
+ scope: 'system',
314
+ createdBy: existing?.createdBy ?? { uid: `module:${rec.name}`, email: 'module@shraga.local' },
315
+ createdAt: existing?.createdAt ?? now,
316
+ updatedAt: now,
317
+ lastRun: existing?.lastRun,
318
+ runCount: existing?.runCount ?? 0,
319
+ managedBy: rec.name,
320
+ };
321
+ const result = scheduler.upsertSchedule(schedule);
322
+ if (!result.ok) console.error(`[modules] ${rec.name}: schedule "${def.def}" rejected: ${result.error}`);
323
+ }
324
+ // Remove managed schedules whose def vanished
325
+ for (const s of managedSchedules(rec.name)) {
326
+ if (!expectedIds.has(s.id)) scheduler.deleteSchedule(s.id);
327
+ }
328
+
329
+ applySeeds(manifest, config);
330
+ }
331
+
332
+ /** Boot-time reconcile of all installed modules. Builtin version bump → auto-reapply folder. */
333
+ export function reconcileInstalledModules(): void {
334
+ const state = loadState();
335
+ let dirty = false;
336
+ for (const rec of state.installed) {
337
+ try {
338
+ if (rec.source === 'builtin') {
339
+ const builtinDir = path.join(BUILTIN_MODULES_DIR, rec.name);
340
+ if (existsSync(builtinDir)) {
341
+ const shipped = readManifest(builtinDir);
342
+ if (shipped.version !== rec.version) {
343
+ copyDir(builtinDir, moduleDir(rec.name));
344
+ rec.version = shipped.version;
345
+ dirty = true;
346
+ console.log(`[modules] ${rec.name}: builtin updated → ${shipped.version}`);
347
+ }
348
+ }
349
+ }
350
+ if (rec.enabled) applyModule(rec);
351
+ } catch (err) {
352
+ console.error(`[modules] reconcile failed for ${rec.name}:`, (err as Error).message);
353
+ }
354
+ }
355
+ if (dirty) saveState(state);
356
+ }
357
+
358
+ // ── Install / enable / disable / uninstall ──────────────────────────────────
359
+
360
+ function copyDir(src: string, dest: string): void {
361
+ rmSync(dest, { recursive: true, force: true });
362
+ mkdirSync(dest, { recursive: true });
363
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
364
+ const s = path.join(src, entry.name);
365
+ const d = path.join(dest, entry.name);
366
+ if (entry.isDirectory()) copyDir(s, d);
367
+ else copyFileSync(s, d);
368
+ }
369
+ }
370
+
371
+ /** Install from a builtin name or an explicit folder path. Idempotent re-install = upgrade
372
+ * (keeps config/enabled/adoptions). Adopts unmanaged same-name artifacts, with backup. */
373
+ export function installModule(opts: { name?: string; path?: string }): InstalledModule {
374
+ let srcDir: string;
375
+ let source: string;
376
+ if (opts.path) {
377
+ // Relative paths are data-root-relative (skills document e.g. "workspace/modules-dev/<name>").
378
+ srcDir = path.isAbsolute(opts.path) ? opts.path : path.resolve(DATA_DIR, opts.path);
379
+ source = srcDir;
380
+ } else if (opts.name) {
381
+ srcDir = path.join(BUILTIN_MODULES_DIR, opts.name);
382
+ source = 'builtin';
383
+ } else {
384
+ throw new Error('install requires "name" (builtin) or "path"');
385
+ }
386
+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) throw new Error(`Module folder not found: ${srcDir}`);
387
+ const manifest = readManifest(srcDir);
388
+ if (opts.name && manifest.name !== opts.name) throw new Error(`Manifest name "${manifest.name}" ≠ requested "${opts.name}"`);
389
+
390
+ const destDir = moduleDir(manifest.name);
391
+ if (path.resolve(srcDir) !== path.resolve(destDir)) copyDir(srcDir, destDir);
392
+ for (const rel of walkFiles(destDir)) dataSync.trackWrite(`modules/${manifest.name}/${rel}`);
393
+
394
+ const state = loadState();
395
+ let rec = state.installed.find((m) => m.name === manifest.name);
396
+ const fresh = !rec;
397
+ if (!rec) {
398
+ rec = {
399
+ name: manifest.name,
400
+ version: manifest.version,
401
+ enabled: true,
402
+ config: effectiveConfig(manifest),
403
+ installedAt: Date.now(),
404
+ source,
405
+ };
406
+ state.installed.push(rec);
407
+ } else {
408
+ rec.version = manifest.version;
409
+ rec.source = source;
410
+ rec.config = { ...effectiveConfig(manifest), ...rec.config };
411
+ }
412
+
413
+ if (fresh) adoptExisting(rec, manifest);
414
+ saveState(state);
415
+ if (rec.enabled) {
416
+ applyModule(rec);
417
+ addDefaultSkills(manifest);
418
+ }
419
+ journal(`module "${manifest.name}"@${manifest.version} ${fresh ? 'installed' : 'reinstalled'}${rec.enabled ? '' : ' (disabled)'}`);
420
+ return rec;
421
+ }
422
+
423
+ /** Adoption (install-time, fresh installs only): unmanaged exact-name matches become managed.
424
+ * Skills: back up original to data/modules/.backups/<name>/*.orig before first overwrite
425
+ * (outside the module folder — survives reinstall/upgrade/uninstall).
426
+ * Schedules: stamp managedBy, keep the existing id, preserve enabled/runCount/lastRun. */
427
+ function adoptExisting(rec: InstalledModule, manifest: ModuleManifest): void {
428
+ for (const file of manifest.skills ?? []) {
429
+ const name = skillNameFor(file);
430
+ const existing = skillPath(name);
431
+ if (!existsSync(existing)) continue;
432
+ const { meta } = parseSkillFrontmatter(readFileSync(existing, 'utf-8'));
433
+ if (meta.managedBy) continue;
434
+ const bakDir = backupDir(rec.name);
435
+ mkdirSync(bakDir, { recursive: true });
436
+ copyFileSync(existing, path.join(bakDir, `${name}.md.orig`));
437
+ dataSync.trackWrite(`modules/.backups/${rec.name}/${name}.md.orig`);
438
+ // Adoption = install-time consent to take the name over: with the backup safe,
439
+ // remove the unmanaged original so applyModule's ownership guard lets the rendered
440
+ // skill in (the guard otherwise protects unmanaged user skills from module clobber).
441
+ unlinkSync(existing);
442
+ dataSync.trackWrite(`skills/${name}.md`);
443
+ console.log(`[modules] ${rec.name}: adopted existing skill "${name}" (backup in modules/.backups/${rec.name}/)`);
444
+ }
445
+ for (const def of manifest.schedules ?? []) {
446
+ const renderedName = renderTemplate(def.name, effectiveConfig(manifest, rec.config));
447
+ const match = scheduler.listSchedules().find((s) => !s.managedBy && s.name === renderedName);
448
+ if (!match) continue;
449
+ (rec.adoptedScheduleIds ??= {})[def.def] = match.id;
450
+ console.log(`[modules] ${rec.name}: adopted existing schedule "${renderedName}" (id ${match.id})`);
451
+ }
452
+ }
453
+
454
+ function* walkFiles(dir: string, prefix = ''): Generator<string> {
455
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
456
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
457
+ if (entry.isDirectory()) yield* walkFiles(path.join(dir, entry.name), rel);
458
+ else yield rel;
459
+ }
460
+ }
461
+
462
+ export function enableModule(name: string): InstalledModule {
463
+ const state = loadState();
464
+ const rec = state.installed.find((m) => m.name === name);
465
+ if (!rec) throw new Error(`Module "${name}" not installed`);
466
+ const manifest = installedManifest(name);
467
+ rec.enabled = true;
468
+ applyModule(rec);
469
+ // Restore per-schedule enabled states captured at disable
470
+ const snapshot = rec.scheduleEnabledSnapshot;
471
+ if (snapshot) {
472
+ for (const [id, enabled] of Object.entries(snapshot)) scheduler.toggleSchedule(id, enabled);
473
+ delete rec.scheduleEnabledSnapshot;
474
+ }
475
+ unstampSeeds(manifest);
476
+ addDefaultSkills(manifest);
477
+ saveState(state);
478
+ journal(`module "${name}" enabled — schedules restored, skills active`);
479
+ return rec;
480
+ }
481
+
482
+ export function disableModule(name: string): InstalledModule {
483
+ const state = loadState();
484
+ const rec = state.installed.find((m) => m.name === name);
485
+ if (!rec) throw new Error(`Module "${name}" not installed`);
486
+ const manifest = installedManifest(name);
487
+
488
+ // Snapshot + disable managed schedules
489
+ const snapshot: Record<string, boolean> = {};
490
+ for (const s of managedSchedules(name)) {
491
+ snapshot[s.id] = s.enabled;
492
+ scheduler.toggleSchedule(s.id, false);
493
+ }
494
+ // Offspring: agent-booked continuations — disable (never delete) so they can't fire into a missing skill
495
+ for (const s of offspringSchedules(manifest)) {
496
+ snapshot[s.id] = s.enabled;
497
+ scheduler.toggleSchedule(s.id, false);
498
+ }
499
+
500
+ // Remove rendered skill files (only ones we own) + drop module entries from skills-defaults
501
+ for (const file of manifest.skills ?? []) {
502
+ const skillName = skillNameFor(file);
503
+ const dest = skillPath(skillName);
504
+ if (existsSync(dest) && skillOwnedBy(skillName, name)) {
505
+ unlinkSync(dest);
506
+ dataSync.trackWrite(`skills/${skillName}.md`);
507
+ }
508
+ }
509
+ removeDefaultSkills(manifest);
510
+ stampSeeds(manifest);
511
+
512
+ rec.enabled = false;
513
+ rec.scheduleEnabledSnapshot = snapshot;
514
+ saveState(state);
515
+ journal(`module "${name}" disabled — schedules paused, skills removed, seeds retained as dormant state`);
516
+ return rec;
517
+ }
518
+
519
+ export function setModuleConfig(name: string, config: Record<string, unknown>): InstalledModule {
520
+ const state = loadState();
521
+ const rec = state.installed.find((m) => m.name === name);
522
+ if (!rec) throw new Error(`Module "${name}" not installed`);
523
+ const manifest = installedManifest(name);
524
+ const schema = manifest.configSchema ?? {};
525
+ for (const [key, val] of Object.entries(config)) {
526
+ const field = schema[key];
527
+ if (!field) throw new Error(`Unknown config key "${key}"`);
528
+ if (typeof val !== field.type) throw new Error(`Config "${key}" must be ${field.type}`);
529
+ }
530
+ rec.config = { ...rec.config, ...(config as Record<string, string | number | boolean>) };
531
+ saveState(state);
532
+ if (rec.enabled) applyModule(rec);
533
+ return rec;
534
+ }
535
+
536
+ /** Uninstall: rendered skills + module-created schedules + folder + state entry go; SEEDS STAY
537
+ * (stamped dormant). Adopted schedules are released (managedBy cleared, disabled), never deleted.
538
+ * Offspring schedules are disabled, never deleted. */
539
+ export function uninstallModule(name: string): void {
540
+ const state = loadState();
541
+ const idx = state.installed.findIndex((m) => m.name === name);
542
+ if (idx < 0) throw new Error(`Module "${name}" not installed`);
543
+ const rec = state.installed[idx];
544
+ let manifest: ModuleManifest | null = null;
545
+ try { manifest = installedManifest(name); } catch { /* folder gone — best-effort cleanup below */ }
546
+
547
+ if (manifest) {
548
+ for (const file of manifest.skills ?? []) {
549
+ const skillName = skillNameFor(file);
550
+ const dest = skillPath(skillName);
551
+ if (existsSync(dest) && skillOwnedBy(skillName, name)) {
552
+ unlinkSync(dest);
553
+ dataSync.trackWrite(`skills/${skillName}.md`);
554
+ }
555
+ }
556
+ removeDefaultSkills(manifest);
557
+ for (const s of offspringSchedules(manifest)) scheduler.toggleSchedule(s.id, false);
558
+ stampSeeds(manifest);
559
+ }
560
+ const adoptedIds = new Set(Object.values(rec.adoptedScheduleIds ?? {}));
561
+ for (const s of managedSchedules(name)) {
562
+ if (adoptedIds.has(s.id)) {
563
+ delete s.managedBy;
564
+ scheduler.toggleSchedule(s.id, false); // persists the un-stamp too
565
+ } else {
566
+ scheduler.deleteSchedule(s.id);
567
+ }
568
+ }
569
+
570
+ rmSync(moduleDir(name), { recursive: true, force: true });
571
+ dataSync.trackWrite(`modules/${name}`);
572
+ state.installed.splice(idx, 1);
573
+ saveState(state);
574
+ journal(`module "${name}" uninstalled — skills/schedules removed, seeds retained as dormant state`);
575
+ }
@@ -0,0 +1,62 @@
1
+ /** Data-plane module system — declarative bundles of skills + schedules + seeds.
2
+ * A module is a folder: module.json (manifest) + skill templates + seeds/.
3
+ * No server code — the service reconciles the folder into data/ at runtime. */
4
+
5
+ import type { Trigger, Task } from '../scheduler/types.ts';
6
+
7
+ /** One field in a module's config schema. `default` doubles as the initial value. */
8
+ export interface ModuleConfigField {
9
+ type: 'string' | 'number' | 'boolean';
10
+ default?: string | number | boolean;
11
+ description?: string;
12
+ }
13
+
14
+ /** A schedule the module owns. `def` is the stable key → schedule id `mod-<module>-<def>`.
15
+ * String fields (trigger/task) may contain `{{key}}` config placeholders. */
16
+ export interface ModuleScheduleDef {
17
+ def: string;
18
+ name: string;
19
+ trigger: Trigger;
20
+ task: Task;
21
+ /** Initial enabled state on first install (default true). Never re-forced by reconcile. */
22
+ enabled?: boolean;
23
+ }
24
+
25
+ export interface ModuleManifest {
26
+ name: string;
27
+ version: string;
28
+ description?: string;
29
+ configSchema?: Record<string, ModuleConfigField>;
30
+ /** Skill template files in the module folder (e.g. "routine.md.tmpl" → data/skills/routine.md). */
31
+ skills?: string[];
32
+ schedules?: ModuleScheduleDef[];
33
+ /** Data-relative paths (e.g. "workspace/agenda.md"); source under <module>/seeds/<path>.
34
+ * Create-if-missing only — seeds are state, never overwritten. */
35
+ seeds?: string[];
36
+ /** Skill names to add to skills-defaults.json on install/enable (not re-added by reconcile). */
37
+ defaultSkills?: string[];
38
+ /** Artifacts the module's DOCTRINE causes the agent to create (not declared above).
39
+ * `schedules` is a glob on schedule NAME (e.g. "self-wake-*"): disable/uninstall
40
+ * disables (never deletes) matches so agent-booked continuations can't fire into
41
+ * a missing skill. */
42
+ offspring?: { schedules?: string };
43
+ }
44
+
45
+ /** Persisted record in data/modules/state.json. */
46
+ export interface InstalledModule {
47
+ name: string;
48
+ version: string;
49
+ enabled: boolean;
50
+ config: Record<string, string | number | boolean>;
51
+ installedAt: number;
52
+ /** Origin: 'builtin' (defaults/modules) or the install path for local installs. */
53
+ source: string;
54
+ /** def → pre-existing schedule id adopted at install (kept instead of mod-<name>-<def>). */
55
+ adoptedScheduleIds?: Record<string, string>;
56
+ /** Per-schedule enabled map captured on disable, restored on enable. */
57
+ scheduleEnabledSnapshot?: Record<string, boolean>;
58
+ }
59
+
60
+ export interface ModulesState {
61
+ installed: InstalledModule[];
62
+ }