monomind 2.5.8 → 2.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.5.8",
3
+ "version": "2.6.0",
4
4
  "description": "Open-source CLI extension for Claude Code. Adds an MCP server with a codebase knowledge graph, persistent memory, multi-agent coordination, and reusable slash commands. MIT licensed, runs locally, no data leaves your machine.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -425,6 +425,35 @@ export const cleanupCommand = {
425
425
  }
426
426
  catch { /* already gone */ }
427
427
  }
428
+ // Kill stale MCP server processes
429
+ const mcpPidPaths = [
430
+ join(require('os').homedir(), '.monomind', 'mcp.pid'),
431
+ join(cwd, '.monomind', 'mcp-server.pid'),
432
+ ];
433
+ for (const pp of mcpPidPaths) {
434
+ try {
435
+ if (!existsSync(pp) || statSync(pp).size > 32)
436
+ continue;
437
+ const pid = parseInt(readFileSync(pp, 'utf-8').trim(), 10);
438
+ if (Number.isInteger(pid) && pid > 0 && looksLikeOurProcess(pid)) {
439
+ process.kill(pid, 'SIGTERM');
440
+ output.writeln(output.info(` Stopped MCP server (pid ${pid})`));
441
+ }
442
+ try {
443
+ unlinkSync(pp);
444
+ }
445
+ catch { }
446
+ }
447
+ catch { /* already gone */ }
448
+ }
449
+ // Reap orphaned claude-agent-sdk processes from crashed orgs
450
+ try {
451
+ const { reapOrphanedSdkProcesses } = await import('../utils/resource-governor.js');
452
+ const reaped = reapOrphanedSdkProcesses(new Set());
453
+ if (reaped > 0)
454
+ output.writeln(output.info(` Reaped ${reaped} orphaned SDK agent process(es)`));
455
+ }
456
+ catch { /* resource-governor not available */ }
428
457
  }
429
458
  output.writeln();
430
459
  output.writeln(output.bold(dryRun
@@ -149,7 +149,7 @@ async function getSystemStatus() {
149
149
  }
150
150
  }
151
151
  // Display status in text format
152
- function displayStatus(status) {
152
+ async function displayStatus(status) {
153
153
  output.writeln();
154
154
  // Header with overall status
155
155
  const statusIcon = status.running
@@ -246,6 +246,27 @@ function displayStatus(status) {
246
246
  `Memory Usage: ${status.performance.memoryUsage.toFixed(1)}%`
247
247
  ]);
248
248
  }
249
+ // System resources section
250
+ output.writeln(output.bold('System Resources'));
251
+ try {
252
+ const { checkResources } = await import('../utils/resource-governor.js');
253
+ const res = checkResources();
254
+ const memColor = res.freeMemPct < 15 ? output.error : res.freeMemPct < 30 ? output.warning : output.success;
255
+ output.printTable({
256
+ columns: [
257
+ { key: 'property', header: 'Property', width: 18 },
258
+ { key: 'value', header: 'Value', width: 25, align: 'right' }
259
+ ],
260
+ data: [
261
+ { property: 'Available RAM', value: memColor(`${res.freeMemMB}MB (${res.freeMemPct}%)`) },
262
+ { property: 'SDK Processes', value: `${res.sdkProcesses} / ${res.maxSdkProcesses} max` },
263
+ { property: 'Status', value: res.ok ? output.success('OK') : output.warning(res.reason ?? 'pressure') },
264
+ ]
265
+ });
266
+ }
267
+ catch {
268
+ output.printInfo(' Resource governor not available');
269
+ }
249
270
  }
250
271
  // Format health status with color
251
272
  function formatHealth(health) {
@@ -290,7 +311,7 @@ const statusAction = async (ctx) => {
290
311
  return watchStatus(interval);
291
312
  }
292
313
  // Single status display
293
- displayStatus(status);
314
+ await displayStatus(status);
294
315
  return { success: true, data: status };
295
316
  };
296
317
  // Perform health checks
@@ -379,7 +400,7 @@ async function watchStatus(intervalSeconds) {
379
400
  output.writeln(output.dim(`Last updated: ${new Date().toLocaleTimeString()}`));
380
401
  output.writeln();
381
402
  const status = await getSystemStatus();
382
- displayStatus(status);
403
+ await displayStatus(status);
383
404
  };
384
405
  // Initial display
385
406
  await refresh();
@@ -54,6 +54,13 @@ export declare class OrgDaemon {
54
54
  listOrgs(): RunningOrg[];
55
55
  getOrg(name: string): RunningOrg | undefined;
56
56
  startOrg(name: string, taskOverride?: string): Promise<RunningOrg>;
57
+ /** A role that failed its resource gate at boot isn't abandoned — keep polling
58
+ * for capacity in the background (bounded) and spawn it the moment resources
59
+ * free up, instead of silently running the org shorthanded for its whole life.
60
+ * Bails quietly if the org is stopped (or restarted under the same name)
61
+ * before capacity returns; `running` is compared by identity, not `name`,
62
+ * so a stale retry can never spawn into a different run. */
63
+ private scheduleDeferredSpawn;
57
64
  /**
58
65
  * Resolves an org_send `to` address ("role" for same-org, "org:role" for
59
66
  * cross-org) into its parts. Centralizes the one addressing rule that
@@ -11,6 +11,7 @@ import { BrokerLease, lookupOrg } from './broker.js';
11
11
  import { queueMessage, drainInbox } from './inbox.js';
12
12
  import { OrgDefSchema, ORG_DIR } from './types.js';
13
13
  import { summarizeRun, readRunEvents, readHistory, historyFile } from './reporting.js';
14
+ import { checkResources, waitForCapacity, getResourceLimits } from '../utils/resource-governor.js';
14
15
  export class OrgDaemon {
15
16
  root;
16
17
  opts;
@@ -102,7 +103,16 @@ export class OrgDaemon {
102
103
  return [];
103
104
  }
104
105
  })();
105
- for (const role of def.roles) {
106
+ // Resource-gated staggered spawn: check memory/process limits before each
107
+ // NON-BOSS agent, wait if under pressure. The boss always spawns immediately
108
+ // and ungated — the org has no coordinator at all without it, so gating it
109
+ // behind host memory pressure would make the whole org fail to start over a
110
+ // condition workers are specifically designed to ride out.
111
+ const limits = getResourceLimits();
112
+ // Extracted so a role that fails its gate check can be spawned later by
113
+ // scheduleDeferredSpawn() once resources free up, without re-running the
114
+ // gate logic or duplicating the session-wiring below.
115
+ const spawnRole = (role) => {
106
116
  const mailbox = new Mailbox();
107
117
  const policy = new PolicyEngine(role.id, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, cwd);
108
118
  const runtime = { mailbox, policy, status: 'running', done: Promise.resolve() };
@@ -194,7 +204,44 @@ export class OrgDaemon {
194
204
  }
195
205
  })();
196
206
  running.agents.set(role.id, runtime);
207
+ };
208
+ spawnRole(bossRole); // always, ungated — see comment above
209
+ for (const role of def.roles) {
210
+ if (role.id === bossRole.id)
211
+ continue;
212
+ // Gate non-boss agents: stagger when under 50% free mem, resource check.
213
+ const check = checkResources();
214
+ // Stagger only when memory pressure exists (< 50% free)
215
+ if (check.freeMemPct < 50) {
216
+ await new Promise(r => { const t = setTimeout(r, limits.spawnStaggerMs); t.unref?.(); });
217
+ }
218
+ if (!check.ok) {
219
+ bus.emit({ type: 'audit', from: role.id, reason: 'resource-pressure',
220
+ msg: `pausing spawn of "${role.id}": ${check.reason} — waiting` });
221
+ const waited = await waitForCapacity(60_000);
222
+ if (!waited.ok) {
223
+ // Deferred, not dropped: a role missing for the org's whole life is
224
+ // worse than a slow retry loop. Retries in the background and
225
+ // spawns the moment capacity returns (see scheduleDeferredSpawn).
226
+ bus.emit({ type: 'audit', from: role.id, reason: 'resource-skip',
227
+ msg: `deferring "${role.id}" — will keep retrying in the background: ${waited.reason}` });
228
+ this.scheduleDeferredSpawn(name, running, role, spawnRole);
229
+ continue;
230
+ }
231
+ }
232
+ spawnRole(role);
197
233
  }
234
+ // Crash cleanup: reap SDK children if this process exits abnormally.
235
+ // monolean: process-scoped listener — upgrade path = per-org tracking
236
+ const crashCleanup = () => {
237
+ try {
238
+ const rg = require('../utils/resource-governor.js');
239
+ rg.reapOrphanedSdkProcesses(new Set(), process.pid);
240
+ }
241
+ catch { /* best-effort */ }
242
+ };
243
+ process.on('exit', crashCleanup);
244
+ running._crashCleanup = crashCleanup;
198
245
  const boss = bossRole;
199
246
  // Cross-run memory: brief the coordinator on the previous run so scheduled
200
247
  // orgs accumulate instead of starting cold every interval.
@@ -272,6 +319,32 @@ export class OrgDaemon {
272
319
  bus.emit({ type: 'status', msg: `drained ${queued.length} queued message(s) from inbox` });
273
320
  return running;
274
321
  }
322
+ /** A role that failed its resource gate at boot isn't abandoned — keep polling
323
+ * for capacity in the background (bounded) and spawn it the moment resources
324
+ * free up, instead of silently running the org shorthanded for its whole life.
325
+ * Bails quietly if the org is stopped (or restarted under the same name)
326
+ * before capacity returns; `running` is compared by identity, not `name`,
327
+ * so a stale retry can never spawn into a different run. */
328
+ scheduleDeferredSpawn(name, running, role, spawnRole) {
329
+ const MAX_ATTEMPTS = 6; // ~30 min of retrying before giving up loudly
330
+ (async () => {
331
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
332
+ const waited = await waitForCapacity(5 * 60_000);
333
+ if (this.orgs.get(name) !== running)
334
+ return; // org stopped/restarted — abandon quietly
335
+ if (waited.ok) {
336
+ running.bus.emit({ type: 'audit', from: role.id, reason: 'resource-recovered',
337
+ msg: `resources recovered after ${attempt} retr${attempt === 1 ? 'y' : 'ies'} — spawning deferred role "${role.id}"` });
338
+ spawnRole(role);
339
+ return;
340
+ }
341
+ running.bus.emit({ type: 'audit', from: role.id, reason: 'resource-pressure',
342
+ msg: `still under pressure (attempt ${attempt}/${MAX_ATTEMPTS}) — retrying "${role.id}" spawn: ${waited.reason}` });
343
+ }
344
+ running.bus.emit({ type: 'audit', from: role.id, reason: 'resource-abandoned',
345
+ msg: `giving up spawning "${role.id}" after ${MAX_ATTEMPTS} retries — org will run without this role until manually restarted` });
346
+ })().catch(err => console.error(`org ${name}: deferred spawn of "${role.id}" failed:`, err instanceof Error ? err.message : err));
347
+ }
275
348
  /**
276
349
  * Resolves an org_send `to` address ("role" for same-org, "org:role" for
277
350
  * cross-org) into its parts. Centralizes the one addressing rule that
@@ -505,6 +578,10 @@ export class OrgDaemon {
505
578
  // register a NEW forwarder under the same name — settling/unsubscribing
506
579
  // that one would sever the new run's dashboard stream.
507
580
  const forwarder = this.forwarders.get(name);
581
+ // Remove crash-cleanup handler — normal stop handles reaping itself
582
+ const cleanup = org._crashCleanup;
583
+ if (cleanup)
584
+ process.removeListener('exit', cleanup);
508
585
  const wd = this.watchdogs.get(name);
509
586
  if (wd) {
510
587
  clearInterval(wd);
@@ -526,6 +603,15 @@ export class OrgDaemon {
526
603
  type: 'audit', msg: `org stop timed out after ${stopWaitMs}ms waiting for agent sessions to finish — proceeding anyway`,
527
604
  reason: 'stop-timeout',
528
605
  });
606
+ // Reap only SDK processes spawned by THIS node process — ownerPid filter
607
+ // ensures other `monomind org run` daemons' agents are untouched.
608
+ try {
609
+ const { reapOrphanedSdkProcesses } = await import('../utils/resource-governor.js');
610
+ const reaped = reapOrphanedSdkProcesses(new Set(), process.pid);
611
+ if (reaped > 0)
612
+ org.bus.emit({ type: 'audit', reason: 'orphan-reap', msg: `reaped ${reaped} orphaned SDK process(es) after stop timeout` });
613
+ }
614
+ catch { /* best-effort */ }
529
615
  }
530
616
  org.bus.emit({ type: 'status', msg: 'org stopped' });
531
617
  await org.bus.flush();
@@ -37,12 +37,16 @@ export function buildRolePrompt(role, def, roster, glossary) {
37
37
  */
38
38
  export async function runAgentSession(opts) {
39
39
  const { mailbox } = opts;
40
+ // Carries the SDK's own session_id across a maxTurns restart so the next
41
+ // query() call resumes the prior conversation instead of starting cold —
42
+ // without this, a restart silently discarded all in-progress reasoning.
43
+ let resumeSessionId;
40
44
  // Always run at least once: a mailbox can be closed with queued items still
41
45
  // pending (stream() drains the queue before honoring `closed`), which is a
42
46
  // normal, valid starting state — checking isClosed before the first run
43
47
  // would skip that drain entirely.
44
48
  while (true) {
45
- await runOneSession(opts);
49
+ resumeSessionId = await runOneSession(opts, resumeSessionId);
46
50
  // The dead session's generator may still hold the waker — drop it so a
47
51
  // push() before the next stream() starts only queues instead of being
48
52
  // consumed by the abandoned generator (silent message loss).
@@ -52,8 +56,8 @@ export async function runAgentSession(opts) {
52
56
  opts.bus.emit({ type: 'status', from: opts.role.id, msg: 'session restarting (turn limit reached, mailbox still open)' });
53
57
  }
54
58
  }
55
- /** One bounded SDK session for a role; resolves when the SDK stream ends (mailbox closed or maxTurns reached). */
56
- async function runOneSession(opts) {
59
+ /** One bounded SDK session for a role; resolves with the SDK's session_id (for resuming on restart) when the stream ends (mailbox closed or maxTurns reached). */
60
+ async function runOneSession(opts, resume) {
57
61
  const { org, role, bus, policy, mailbox, cwd, deliver } = opts;
58
62
  const queryFn = opts.queryFn ?? query;
59
63
  const orgServer = createSdkMcpServer({
@@ -101,6 +105,7 @@ async function runOneSession(opts) {
101
105
  ],
102
106
  });
103
107
  bus.emit({ type: 'status', from: role.id, msg: 'session starting' });
108
+ let sessionId = resume;
104
109
  try {
105
110
  const stream = queryFn({
106
111
  prompt: mailbox.stream(),
@@ -112,6 +117,7 @@ async function runOneSession(opts) {
112
117
  mcpServers: { org: orgServer },
113
118
  maxTurns: opts.maxTurns ?? 30,
114
119
  permissionMode: 'default',
120
+ resume,
115
121
  canUseTool: async (toolName, input) => policy.decide(toolName, input),
116
122
  // test seam: lets the scripted fake SDK (test-loop.ts) drive org_send and
117
123
  // tool calls through the real deliver/policy paths; the real SDK ignores it
@@ -122,6 +128,8 @@ async function runOneSession(opts) {
122
128
  },
123
129
  });
124
130
  for await (const m of stream) {
131
+ if (m.session_id)
132
+ sessionId = m.session_id;
125
133
  if (m.type === 'assistant') {
126
134
  const text = (m.message?.content ?? [])
127
135
  .filter((b) => b.type === 'text').map((b) => b.text).join('\n');
@@ -139,6 +147,7 @@ async function runOneSession(opts) {
139
147
  }
140
148
  }
141
149
  bus.emit({ type: 'status', from: role.id, msg: 'session ended' });
150
+ return sessionId;
142
151
  }
143
152
  catch (err) {
144
153
  bus.emit({ type: 'status', from: role.id, msg: `session error: ${err.message}` });
@@ -0,0 +1,32 @@
1
+ export interface ResourceLimits {
2
+ /** Minimum free memory (bytes) required before spawning. Default: 15% of total. */
3
+ minFreeMemBytes: number;
4
+ /** Maximum concurrent claude-agent-sdk processes across all orgs. Default: cpus - 2, min 2. */
5
+ maxSdkProcesses: number;
6
+ /** Delay between sequential agent spawns (ms). Default: 2000. */
7
+ spawnStaggerMs: number;
8
+ }
9
+ export declare function configureResourceLimits(o: Partial<ResourceLimits>): void;
10
+ export declare function getResourceLimits(): ResourceLimits;
11
+ /** Available memory in bytes — free + reclaimable (inactive/speculative/purgeable on macOS).
12
+ * os.freemem() on macOS returns only wired-free pages, which is near-zero on
13
+ * a warm system even though GB of file-cache are instantly reclaimable. */
14
+ export declare function getAvailableMemBytes(): number;
15
+ export declare function countSdkProcesses(): number;
16
+ export interface ResourceCheck {
17
+ ok: boolean;
18
+ freeMemMB: number;
19
+ freeMemPct: number;
20
+ sdkProcesses: number;
21
+ maxSdkProcesses: number;
22
+ reason?: string;
23
+ }
24
+ export declare function checkResources(): ResourceCheck;
25
+ /** Wait until resources are available, with a timeout. Returns false if timed out. */
26
+ export declare function waitForCapacity(timeoutMs?: number): Promise<ResourceCheck>;
27
+ /** Kill orphaned claude-agent-sdk processes.
28
+ * @param protectedPids PIDs to never kill (e.g. sibling org agents).
29
+ * @param ownerPid Only kill SDK processes whose parent is this PID.
30
+ * Prevents killing agents from OTHER monomind org processes. */
31
+ export declare function reapOrphanedSdkProcesses(protectedPids: Set<number>, ownerPid?: number): number;
32
+ //# sourceMappingURL=resource-governor.d.ts.map
@@ -0,0 +1,115 @@
1
+ // packages/@monomind/cli/src/utils/resource-governor.ts
2
+ // monolean: single-module resource gate — upgrade path = cgroup integration
3
+ import { freemem, totalmem, cpus, platform } from 'node:os';
4
+ import { execSync } from 'node:child_process';
5
+ const defaults = () => ({
6
+ minFreeMemBytes: parseInt(process.env.MONOMIND_MIN_FREE_MEM_MB || '0', 10) * 1024 * 1024
7
+ || Math.floor(totalmem() * 0.15),
8
+ maxSdkProcesses: parseInt(process.env.MONOMIND_MAX_SDK_PROCS || '0', 10)
9
+ || Math.max(2, cpus().length - 2),
10
+ spawnStaggerMs: parseInt(process.env.MONOMIND_SPAWN_STAGGER_MS || '0', 10) || 2000,
11
+ });
12
+ let overrides = {};
13
+ export function configureResourceLimits(o) {
14
+ overrides = { ...overrides, ...o };
15
+ }
16
+ export function getResourceLimits() {
17
+ return { ...defaults(), ...overrides };
18
+ }
19
+ /** Available memory in bytes — free + reclaimable (inactive/speculative/purgeable on macOS).
20
+ * os.freemem() on macOS returns only wired-free pages, which is near-zero on
21
+ * a warm system even though GB of file-cache are instantly reclaimable. */
22
+ export function getAvailableMemBytes() {
23
+ if (platform() === 'darwin') {
24
+ try {
25
+ const out = execSync('vm_stat', { encoding: 'utf8', timeout: 3000 });
26
+ const page = (out.match(/page size of (\d+)/) ?? [])[1];
27
+ const free = (out.match(/Pages free:\s+(\d+)/) ?? [])[1];
28
+ const inactive = (out.match(/Pages inactive:\s+(\d+)/) ?? [])[1];
29
+ const speculative = (out.match(/Pages speculative:\s+(\d+)/) ?? [])[1];
30
+ const purgeable = (out.match(/Pages purgeable:\s+(\d+)/) ?? [])[1];
31
+ if (page && free) {
32
+ const ps = parseInt(page, 10);
33
+ return ps * (parseInt(free, 10) + parseInt(inactive || '0', 10) + parseInt(speculative || '0', 10) + parseInt(purgeable || '0', 10));
34
+ }
35
+ }
36
+ catch { /* fall through */ }
37
+ }
38
+ return freemem();
39
+ }
40
+ export function countSdkProcesses() {
41
+ try {
42
+ // Match only actual SDK agent binaries (have --output-format in argv),
43
+ // not processes that merely reference the SDK package path.
44
+ const out = execSync('pgrep -f "claude-agent-sdk.*--output-format"', { encoding: 'utf8', timeout: 5000 });
45
+ return out.trim().split('\n').filter(Boolean).length;
46
+ }
47
+ catch {
48
+ return 0;
49
+ } // pgrep exits 1 when no matches
50
+ }
51
+ export function checkResources() {
52
+ const limits = getResourceLimits();
53
+ const free = getAvailableMemBytes();
54
+ const total = totalmem();
55
+ const freeMemMB = Math.round(free / 1024 / 1024);
56
+ const freeMemPct = Math.round((free / total) * 100);
57
+ const sdkProcesses = countSdkProcesses();
58
+ if (free < limits.minFreeMemBytes) {
59
+ return {
60
+ ok: false, freeMemMB, freeMemPct, sdkProcesses,
61
+ maxSdkProcesses: limits.maxSdkProcesses,
62
+ reason: `low memory: ${freeMemMB}MB free (${freeMemPct}%), need ${Math.round(limits.minFreeMemBytes / 1024 / 1024)}MB`,
63
+ };
64
+ }
65
+ if (sdkProcesses >= limits.maxSdkProcesses) {
66
+ return {
67
+ ok: false, freeMemMB, freeMemPct, sdkProcesses,
68
+ maxSdkProcesses: limits.maxSdkProcesses,
69
+ reason: `too many SDK processes: ${sdkProcesses}/${limits.maxSdkProcesses}`,
70
+ };
71
+ }
72
+ return { ok: true, freeMemMB, freeMemPct, sdkProcesses, maxSdkProcesses: limits.maxSdkProcesses };
73
+ }
74
+ /** Wait until resources are available, with a timeout. Returns false if timed out. */
75
+ export async function waitForCapacity(timeoutMs = 60_000) {
76
+ const start = Date.now();
77
+ while (Date.now() - start < timeoutMs) {
78
+ const check = checkResources();
79
+ if (check.ok)
80
+ return check;
81
+ await new Promise(r => { const t = setTimeout(r, 3000); t.unref?.(); });
82
+ }
83
+ return checkResources();
84
+ }
85
+ /** Kill orphaned claude-agent-sdk processes.
86
+ * @param protectedPids PIDs to never kill (e.g. sibling org agents).
87
+ * @param ownerPid Only kill SDK processes whose parent is this PID.
88
+ * Prevents killing agents from OTHER monomind org processes. */
89
+ export function reapOrphanedSdkProcesses(protectedPids, ownerPid) {
90
+ try {
91
+ const out = execSync('ps -eo pid,ppid,command', { encoding: 'utf8', timeout: 5000 });
92
+ let reaped = 0;
93
+ for (const line of out.split('\n')) {
94
+ if (!line.includes('claude-agent-sdk') || !line.includes('--output-format'))
95
+ continue;
96
+ const parts = line.trim().split(/\s+/);
97
+ const pid = parseInt(parts[0], 10);
98
+ const ppid = parseInt(parts[1], 10);
99
+ if (isNaN(pid) || protectedPids.has(pid))
100
+ continue;
101
+ if (ownerPid != null && ppid !== ownerPid)
102
+ continue;
103
+ try {
104
+ process.kill(pid, 'SIGTERM');
105
+ reaped++;
106
+ }
107
+ catch { /* already dead */ }
108
+ }
109
+ return reaped;
110
+ }
111
+ catch {
112
+ return 0;
113
+ }
114
+ }
115
+ //# sourceMappingURL=resource-governor.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.5.8",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "CLI engine for Monomind \u2014 an open-source MCP server that extends Claude Code with a codebase knowledge graph (tree-sitter + SQLite), persistent memory, multi-agent task coordination, and session hooks. MIT licensed, fully local.",
6
6
  "main": "dist/src/index.js",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Renders the public content calendar (HTML + MD) from each growth org's
3
- // workspace/posts.md the single canonical, ready-to-publish post ledger.
4
- // Regenerate after any growth org run:
2
+ // Renders the public content calendar (HTML + MD) from monomind-growth's
3
+ // workspace/articles.md (6 pillar articles) + workspace/posts.md (channel
4
+ // posts derived from those articles, reusing their images). Regenerate
5
+ // after any growth org run:
5
6
  // node scripts/growth-content-calendar.mjs
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
@@ -13,30 +14,64 @@ const ORGS = [
13
14
  ];
14
15
  const OUT_DIR = path.join(ROOT, '.monomind/orgs/monomind-growth/workspace/reports/content-calendar');
15
16
 
17
+ // Record format in articles.md (one per pillar article):
18
+ //
19
+ // ### <slug> — <Title>
20
+ // HeroImage: <filename>
21
+ // SupportingImages: <filename>, <filename>
22
+ // <full article body>
23
+ function parseArticles(text, org) {
24
+ const articles = [];
25
+ const blocks = text.split(/^### /m).slice(1);
26
+ for (const block of blocks) {
27
+ const lines = block.split('\n');
28
+ const m = lines[0].match(/^(.+?)\s*—\s*(.+?)\s*$/);
29
+ if (!m) continue;
30
+ const [, slug, title] = m;
31
+ let heroImage = null;
32
+ let supportingImages = [];
33
+ const bodyLines = [];
34
+ for (const line of lines.slice(1)) {
35
+ const heroMatch = line.match(/^HeroImage:\s*(.+?)\s*$/i);
36
+ const suppMatch = line.match(/^SupportingImages:\s*(.+?)\s*$/i);
37
+ if (heroMatch) { heroImage = heroMatch[1]; continue; }
38
+ if (suppMatch) { supportingImages = suppMatch[1].split(',').map((s) => s.trim()).filter(Boolean); continue; }
39
+ bodyLines.push(line);
40
+ }
41
+ const body = bodyLines.join('\n').trim();
42
+ if (!body) continue;
43
+ articles.push({ org, slug: slug.trim(), title: title.trim(), heroImage, supportingImages, body });
44
+ }
45
+ return articles;
46
+ }
47
+
16
48
  // Record format in posts.md (one per finalized, ready-to-publish post):
17
49
  //
18
50
  // ### <Channel> — <YYYY-MM-DD>
19
- // Image: <filename in ../assets/>
20
- // <final publish-ready text, one or more lines, until the next "### " or EOF>
21
- function parsePosts(text, org, sourceFile) {
51
+ // Article: <slug>
52
+ // Image: <filename>
53
+ // <final publish-ready text, until the next "### " or EOF>
54
+ function parsePosts(text, org) {
22
55
  const posts = [];
23
56
  const blocks = text.split(/^### /m).slice(1);
24
57
  for (const block of blocks) {
25
58
  const lines = block.split('\n');
26
- const headerLine = lines[0];
27
- const m = headerLine.match(/^(.+?)\s*—\s*(\d{4}-\d{2}-\d{2})\s*$/);
59
+ const m = lines[0].match(/^(.+?)\s*—\s*(\d{4}-\d{2}-\d{2})\s*$/);
28
60
  if (!m) continue;
29
61
  const [, channel, date] = m;
30
62
  let image = null;
63
+ let article = null;
31
64
  const bodyLines = [];
32
65
  for (const line of lines.slice(1)) {
33
66
  const imgMatch = line.match(/^Image:\s*(.+?)\s*$/i);
67
+ const articleMatch = line.match(/^Article:\s*(.+?)\s*$/i);
34
68
  if (imgMatch) { image = imgMatch[1]; continue; }
69
+ if (articleMatch) { article = articleMatch[1]; continue; }
35
70
  bodyLines.push(line);
36
71
  }
37
72
  const text = bodyLines.join('\n').trim();
38
73
  if (!text) continue;
39
- posts.push({ org, sourceFile, channel: channel.trim(), date, text, image });
74
+ posts.push({ org, channel: channel.trim(), date, article, text, image });
40
75
  }
41
76
  return posts;
42
77
  }
@@ -45,17 +80,32 @@ function escapeHtml(s) {
45
80
  return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
46
81
  }
47
82
 
83
+ function mdToHtml(md) {
84
+ // Minimal renderer: paragraphs, headings, code fences, bold/italic, links, tables.
85
+ return escapeHtml(md)
86
+ .replace(/^### (.+)$/gm, '<h4>$1</h4>')
87
+ .replace(/^## (.+)$/gm, '<h3>$1</h3>')
88
+ .replace(/^# (.+)$/gm, '<h2>$1</h2>')
89
+ .replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`)
90
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
91
+ .replace(/\n\n/g, '</p><p>');
92
+ }
93
+
48
94
  function render() {
49
95
  fs.mkdirSync(OUT_DIR, { recursive: true });
50
96
 
97
+ let articles = [];
51
98
  let posts = [];
52
99
  for (const org of ORGS) {
100
+ const articlesFile = path.join(org.dir, 'articles.md');
101
+ if (fs.existsSync(articlesFile)) articles.push(...parseArticles(fs.readFileSync(articlesFile, 'utf8'), org.name));
53
102
  const postsFile = path.join(org.dir, 'posts.md');
54
- if (!fs.existsSync(postsFile)) continue;
55
- const text = fs.readFileSync(postsFile, 'utf8');
56
- posts.push(...parsePosts(text, org.name, path.relative(ROOT, postsFile)));
103
+ if (fs.existsSync(postsFile)) posts.push(...parsePosts(fs.readFileSync(postsFile, 'utf8'), org.name));
57
104
  }
58
105
 
106
+ // dedupe articles by slug (growth-execution mirrors monomind-growth)
107
+ { const bySlug = new Map(); for (const a of articles) if (!bySlug.has(a.slug)) bySlug.set(a.slug, a); articles = [...bySlug.values()]; }
108
+
59
109
  // dedupe identical posts mirrored across both orgs
60
110
  const merged = new Map();
61
111
  for (const p of posts) {
@@ -66,13 +116,18 @@ function render() {
66
116
  }
67
117
  posts = [...merged.values()].sort((a, b) => b.date.localeCompare(a.date));
68
118
 
69
- // copy referenced images alongside html/md
119
+ const articleBySlug = new Map(articles.map((a) => [a.slug, a]));
120
+
121
+ // copy every referenced image (article hero/supporting + post images) alongside html/md
70
122
  const copied = new Set();
71
- for (const p of posts) {
72
- if (!p.image) continue;
123
+ const allImageNames = new Set([
124
+ ...articles.flatMap((a) => [a.heroImage, ...a.supportingImages].filter(Boolean)),
125
+ ...posts.map((p) => p.image).filter(Boolean),
126
+ ]);
127
+ for (const name of allImageNames) {
73
128
  for (const org of ORGS) {
74
- const src = path.join(org.dir, 'assets', p.image);
75
- if (fs.existsSync(src)) { fs.copyFileSync(src, path.join(OUT_DIR, p.image)); copied.add(p.image); break; }
129
+ const src = path.join(org.dir, 'assets', name);
130
+ if (fs.existsSync(src)) { fs.copyFileSync(src, path.join(OUT_DIR, name)); copied.add(name); break; }
76
131
  }
77
132
  }
78
133
 
@@ -80,45 +135,85 @@ function render() {
80
135
  const md = [];
81
136
  md.push('# Monomind Growth — Content Calendar (auto-generated)');
82
137
  md.push('');
83
- md.push('Regenerated by `scripts/growth-content-calendar.mjs` from each org\'s `workspace/posts.md`. Newest publish date first.');
138
+ md.push("Regenerated by `scripts/growth-content-calendar.mjs` from workspace/articles.md (pillar articles) + workspace/posts.md (channel posts derived from them). Newest publish date first.");
84
139
  md.push('');
140
+ md.push(`## Pillar Articles (${articles.length}/6)`);
141
+ for (const a of articles) {
142
+ md.push(`\n### ${a.title} \`${a.slug}\``);
143
+ if (a.heroImage) md.push(`\n![${a.title}](./${a.heroImage})`);
144
+ md.push(`\n${a.body}`);
145
+ }
146
+ md.push('\n---\n\n## Channel Calendar');
85
147
  let lastDate = null;
86
148
  for (const p of posts) {
87
149
  if (p.date !== lastDate) { md.push(`\n## ${p.date}`); lastDate = p.date; }
88
- md.push(`\n### ${p.channel}\n`);
150
+ const src = articleBySlug.get(p.article);
151
+ md.push(`\n### ${p.channel}${src ? ` — based on _"${src.title}"_` : ''}\n`);
89
152
  md.push(p.text);
90
153
  if (p.image) md.push(`\n![${p.channel} image](./${p.image})`);
91
154
  }
92
- if (!posts.length) md.push('\n_No finalized posts yet — workspace/posts.md is empty for both orgs._');
155
+ if (!articles.length && !posts.length) md.push('\n_Nothing finalized yet — articles.md and posts.md are empty for both orgs._');
93
156
  fs.writeFileSync(path.join(OUT_DIR, 'content-calendar.md'), md.join('\n') + '\n');
94
157
 
95
- // --- HTML ---
158
+ // --- HTML (monoes design system: espresso/ivory/gold) ---
96
159
  const groups = [];
97
160
  { let cur = null; for (const p of posts) { if (p.date !== cur?.date) { cur = { date: p.date, items: [] }; groups.push(cur); } cur.items.push(p); } }
98
161
 
99
- function card(p) {
162
+ function postCard(p) {
100
163
  const img = p.image && copied.has(p.image) ? `<img class="thumb" src="./${escapeHtml(p.image)}" alt="">` : '';
164
+ const src = articleBySlug.get(p.article);
101
165
  return `<div class="card">
102
- <div class="channel">${escapeHtml(p.channel)}</div>
166
+ <div class="channel">${escapeHtml(p.channel)}${src ? ` <span class="based-on">based on "${escapeHtml(src.title)}"</span>` : ''}</div>
103
167
  ${img}
104
168
  <div class="text">${escapeHtml(p.text).replace(/\n/g, '<br>')}</div>
105
169
  </div>`;
106
170
  }
107
171
 
172
+ function articleCard(a) {
173
+ const img = a.heroImage && copied.has(a.heroImage) ? `<img class="hero" src="./${escapeHtml(a.heroImage)}" alt="">` : '';
174
+ const supp = a.supportingImages.filter((n) => copied.has(n)).map((n) => `<img class="supporting" src="./${escapeHtml(n)}" alt="">`).join('');
175
+ return `<div class="article">
176
+ <div class="article-slug">${escapeHtml(a.slug)}</div>
177
+ <h3 class="article-title">${escapeHtml(a.title)}</h3>
178
+ ${img}
179
+ ${supp ? `<div class="supporting-row">${supp}</div>` : ''}
180
+ <div class="article-body"><p>${mdToHtml(a.body)}</p></div>
181
+ </div>`;
182
+ }
183
+
108
184
  const html = `<!doctype html>
109
185
  <html><head><meta charset="utf-8"><title>Monomind Growth — Content Calendar</title>
110
186
  <style>
111
- :root { color-scheme: light dark; --bg:#0b0d12; --card:#151922; --line:#232837; --text:#e7ebf3; --muted:#8a93a6; --accent:#7cc4ff; }
112
- @media (prefers-color-scheme: light) { :root { --bg:#f6f7fb; --card:#ffffff; --line:#e4e7ef; --text:#171923; --muted:#5a6270; --accent:#2563eb; } }
187
+ :root {
188
+ --ivory:#FAF7F0; --ivory-warm:#F5F0E6; --ivory-deep:#EDE8DC;
189
+ --espresso:#2A2318; --espresso-deep:#1a1208; --espresso-mid:#3D3228; --espresso-light:#5C4F3D;
190
+ --gold:#C8A97E; --gold-warm:#D4A84A;
191
+ color-scheme: light dark;
192
+ --bg: var(--espresso-deep); --surface: var(--espresso); --card: var(--espresso-mid);
193
+ --border: var(--espresso-light); --text: var(--ivory); --muted: rgba(250,247,240,0.55); --accent: var(--gold);
194
+ }
195
+ @media (prefers-color-scheme: light) {
196
+ :root { --bg: var(--ivory); --surface: var(--ivory-warm); --card: #ffffff; --border: var(--ivory-deep); --text: var(--espresso); --muted: #6b5f4d; --accent: #8B6914; }
197
+ }
113
198
  * { box-sizing: border-box; }
114
- body { margin:0; background:var(--bg); color:var(--text); font:15px/1.55 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif; }
115
- header { padding:2.5rem 1.5rem 1rem; max-width:820px; margin:0 auto; }
116
- h1 { margin:0 0 .3rem; font-size:1.6rem; }
117
- .sub { color:var(--muted); font-size:.9rem; }
118
- main { max-width:820px; margin:0 auto; padding:0 1.5rem 4rem; }
119
- .date-heading { font-size:1.05rem; font-weight:700; color:var(--accent); border-bottom:1px solid var(--line); padding:1.6rem 0 .5rem; margin-bottom:1rem; }
120
- .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:1.1rem 1.3rem; margin-bottom:1rem; }
199
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.6 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif; }
200
+ header { padding:2.5rem 1.5rem 1rem; max-width:860px; margin:0 auto; }
201
+ h1 { margin:0 0 .3rem; font-size:1.7rem; font-weight:300; letter-spacing:.01em; }
202
+ .sub { color:var(--muted); font-size:.85rem; }
203
+ main { max-width:860px; margin:0 auto; padding:0 1.5rem 4rem; }
204
+ .section-heading { font-size:1.1rem; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:.06em; border-bottom:1px solid var(--border); padding:2rem 0 .6rem; margin-bottom:1.2rem; }
205
+ .date-heading { font-size:1.0rem; font-weight:700; color:var(--accent); padding:1.4rem 0 .5rem; }
206
+ .article { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.5rem 1.7rem; margin-bottom:1.5rem; }
207
+ .article-slug { font:11px ui-monospace,monospace; color:var(--accent); text-transform:uppercase; letter-spacing:.08em; margin-bottom:.3rem; }
208
+ .article-title { margin:0 0 1rem; font-size:1.3rem; font-weight:400; }
209
+ .article .hero { width:100%; border-radius:10px; margin-bottom:1rem; display:block; }
210
+ .supporting-row { display:flex; gap:.6rem; margin-bottom:1rem; }
211
+ .supporting-row img { width:100%; border-radius:8px; }
212
+ .article-body { font-size:.92rem; color:var(--text); }
213
+ .article-body p { white-space:pre-wrap; }
214
+ .card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:1.1rem 1.3rem; margin-bottom:1rem; }
121
215
  .channel { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); margin-bottom:.6rem; }
216
+ .based-on { font-weight:400; text-transform:none; letter-spacing:0; color:var(--muted); font-style:italic; }
122
217
  .thumb { max-width:100%; border-radius:8px; margin-bottom:.7rem; display:block; }
123
218
  .text { font-size:.92rem; white-space:normal; }
124
219
  .empty { color:var(--muted); padding:2rem 0; }
@@ -126,15 +221,17 @@ main { max-width:820px; margin:0 auto; padding:0 1.5rem 4rem; }
126
221
  <body>
127
222
  <header>
128
223
  <h1>Monomind Growth — Content Calendar</h1>
129
- <div class="sub">Auto-generated by <code>scripts/growth-content-calendar.mjs</code> from workspace/posts.md. Newest publish date first.</div>
224
+ <div class="sub">Auto-generated from workspace/articles.md (pillar articles) + workspace/posts.md (channel posts derived from them). Newest publish date first.</div>
130
225
  </header>
131
226
  <main>
132
- ${groups.length ? groups.map((g) => `<div class="date-heading">${g.date}</div>${g.items.map(card).join('\n')}`).join('\n') : '<div class="empty">No finalized posts yet — workspace/posts.md is empty for both orgs.</div>'}
227
+ ${articles.length ? `<div class="section-heading">Pillar Articles (${articles.length}/6)</div>${articles.map(articleCard).join('\n')}` : ''}
228
+ ${groups.length ? `<div class="section-heading">Channel Calendar</div>${groups.map((g) => `<div class="date-heading">${g.date}</div>${g.items.map(postCard).join('\n')}`).join('\n')}` : ''}
229
+ ${!articles.length && !posts.length ? '<div class="empty">Nothing finalized yet — articles.md and posts.md are empty for both orgs.</div>' : ''}
133
230
  </main>
134
231
  </body></html>`;
135
232
  fs.writeFileSync(path.join(OUT_DIR, 'content-calendar.html'), html);
136
233
 
137
- console.log(`Wrote ${posts.length} post(s), ${copied.size} image(s) -> ${path.relative(ROOT, OUT_DIR)}`);
234
+ console.log(`Wrote ${articles.length} article(s), ${posts.length} post(s), ${copied.size} image(s) -> ${path.relative(ROOT, OUT_DIR)}`);
138
235
  }
139
236
 
140
237
  render();