monomind 2.5.8 → 2.6.1
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 +13 -6
- package/packages/@monomind/cli/dist/src/commands/cleanup.js +29 -0
- package/packages/@monomind/cli/dist/src/commands/status.js +24 -3
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +7 -0
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +87 -1
- package/packages/@monomind/cli/dist/src/orgrt/session.js +12 -3
- package/packages/@monomind/cli/dist/src/utils/resource-governor.d.ts +32 -0
- package/packages/@monomind/cli/dist/src/utils/resource-governor.js +115 -0
- package/packages/@monomind/cli/package.json +1 -1
- package/scripts/growth-content-calendar.mjs +132 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "monomind",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
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",
|
|
@@ -78,10 +78,14 @@
|
|
|
78
78
|
"axios": ">=1.16.0",
|
|
79
79
|
"qs": ">=6.15.2",
|
|
80
80
|
"esbuild": ">=0.28.1",
|
|
81
|
-
"protobufjs": ">=
|
|
81
|
+
"protobufjs": ">=8.6.6",
|
|
82
82
|
"@protobufjs/utf8": ">=1.1.1",
|
|
83
83
|
"@grpc/grpc-js": ">=1.14.4",
|
|
84
|
-
"onnxruntime-node": ">=1.27.0"
|
|
84
|
+
"onnxruntime-node": ">=1.27.0",
|
|
85
|
+
"fast-uri": ">=4.1.1",
|
|
86
|
+
"sharp": ">=0.35.0",
|
|
87
|
+
"adm-zip": ">=0.6.0",
|
|
88
|
+
"body-parser": ">=1.20.6"
|
|
85
89
|
},
|
|
86
90
|
"pnpm": {
|
|
87
91
|
"overrides": {
|
|
@@ -90,12 +94,12 @@
|
|
|
90
94
|
"axios": ">=1.16.0",
|
|
91
95
|
"qs": ">=6.15.2",
|
|
92
96
|
"esbuild": ">=0.28.1",
|
|
93
|
-
"protobufjs": ">=
|
|
97
|
+
"protobufjs": ">=8.6.6",
|
|
94
98
|
"@protobufjs/utf8": ">=1.1.1",
|
|
95
99
|
"@grpc/grpc-js": ">=1.14.4",
|
|
96
100
|
"vite": ">=8.0.16",
|
|
97
101
|
"postcss": ">=8.5.10",
|
|
98
|
-
"fast-uri": ">=
|
|
102
|
+
"fast-uri": ">=4.1.1",
|
|
99
103
|
"form-data": ">=4.0.6",
|
|
100
104
|
"ip-address": ">=10.1.1",
|
|
101
105
|
"js-yaml": ">=4.2.0",
|
|
@@ -104,7 +108,10 @@
|
|
|
104
108
|
"follow-redirects": ">=1.16.0",
|
|
105
109
|
"@anthropic-ai/claude-code": ">=2.1.163",
|
|
106
110
|
"handlebars": ">=4.7.9",
|
|
107
|
-
"onnxruntime-node": ">=1.27.0"
|
|
111
|
+
"onnxruntime-node": ">=1.27.0",
|
|
112
|
+
"sharp": ">=0.35.0",
|
|
113
|
+
"adm-zip": ">=0.6.0",
|
|
114
|
+
"body-parser": ">=1.20.6"
|
|
108
115
|
},
|
|
109
116
|
"peerDependencyRules": {
|
|
110
117
|
"allowedVersions": {
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.6.1",
|
|
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
|
|
3
|
-
// workspace/
|
|
4
|
-
//
|
|
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
|
-
//
|
|
20
|
-
// <
|
|
21
|
-
|
|
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
|
|
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,
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 (
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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',
|
|
75
|
-
if (fs.existsSync(src)) { fs.copyFileSync(src, path.join(OUT_DIR,
|
|
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(
|
|
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`);
|
|
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
|
-
|
|
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`);
|
|
91
154
|
}
|
|
92
|
-
if (!posts.length) md.push('\
|
|
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
|
|
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 {
|
|
112
|
-
|
|
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.
|
|
115
|
-
header { padding:2.5rem 1.5rem 1rem; max-width:
|
|
116
|
-
h1 { margin:0 0 .3rem; font-size:1.
|
|
117
|
-
.sub { color:var(--muted); font-size:.
|
|
118
|
-
main { max-width:
|
|
119
|
-
.
|
|
120
|
-
.
|
|
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
|
|
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
|
-
${
|
|
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();
|