@balacode/mental 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.cursor-plugin/plugin.json +21 -0
  4. package/.mcp.json +8 -0
  5. package/CHANGELOG.md +42 -0
  6. package/LICENSE +21 -0
  7. package/README.md +277 -0
  8. package/assets/logo.svg +19 -0
  9. package/bin/cli.mjs +135 -0
  10. package/bin/commands/attention.mjs +139 -0
  11. package/bin/commands/decide.mjs +104 -0
  12. package/bin/commands/doctor.mjs +150 -0
  13. package/bin/commands/heartbeat.mjs +21 -0
  14. package/bin/commands/hooks.mjs +41 -0
  15. package/bin/commands/install.mjs +86 -0
  16. package/bin/commands/journal.mjs +54 -0
  17. package/bin/commands/link.mjs +18 -0
  18. package/bin/commands/list.mjs +51 -0
  19. package/bin/commands/local.mjs +118 -0
  20. package/bin/commands/note.mjs +61 -0
  21. package/bin/commands/reindex.mjs +48 -0
  22. package/bin/commands/remap.mjs +76 -0
  23. package/bin/commands/search.mjs +55 -0
  24. package/bin/commands/serve.mjs +16 -0
  25. package/bin/commands/show.mjs +61 -0
  26. package/bin/commands/split.mjs +56 -0
  27. package/bin/commands/status.mjs +136 -0
  28. package/bin/commands/uninstall.mjs +58 -0
  29. package/bin/commands/where.mjs +29 -0
  30. package/bin/lib/args.mjs +117 -0
  31. package/bin/lib/bindings.mjs +404 -0
  32. package/bin/lib/entry.mjs +35 -0
  33. package/bin/lib/git.mjs +149 -0
  34. package/bin/lib/heartbeat.mjs +118 -0
  35. package/bin/lib/hooks.mjs +144 -0
  36. package/bin/lib/ignore.mjs +122 -0
  37. package/bin/lib/import-legacy.mjs +183 -0
  38. package/bin/lib/index.mjs +574 -0
  39. package/bin/lib/install-cli.mjs +100 -0
  40. package/bin/lib/install-skills.mjs +120 -0
  41. package/bin/lib/mcp.mjs +389 -0
  42. package/bin/lib/okf.mjs +746 -0
  43. package/bin/lib/output.mjs +112 -0
  44. package/bin/lib/pkg.mjs +22 -0
  45. package/bin/lib/resolve.mjs +302 -0
  46. package/bin/lib/uninstall.mjs +56 -0
  47. package/hooks/session-start.sh +4 -0
  48. package/mcp.json +11 -0
  49. package/package.json +43 -0
  50. package/plugin.json +21 -0
  51. package/rules/mental.mdc +18 -0
  52. package/skills/mental/SKILL.md +277 -0
  53. package/skills/mental/references/templates.md +186 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Copy the Mental skill + tiny rule into user agent dirs (one source in-repo).
3
+ */
4
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { BEGIN, END, RULES_DIR, SKILLS_DIR } from "./pkg.mjs";
7
+
8
+ export function skillSourceDir() {
9
+ return join(SKILLS_DIR, "mental");
10
+ }
11
+
12
+ export function ruleSourceFile() {
13
+ return join(RULES_DIR, "mental.mdc");
14
+ }
15
+
16
+ /** Body of the always-on rule (frontmatter stripped) — single source is rules/mental.mdc. */
17
+ export function ruleBodyText() {
18
+ const raw = readFileSync(ruleSourceFile(), "utf8");
19
+ const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
20
+ return (m ? m[1] : raw).trim();
21
+ }
22
+
23
+ /**
24
+ * User-global skill/rule destinations under $HOME.
25
+ * @param {string} home
26
+ */
27
+ export function userInstallTargets(home) {
28
+ return {
29
+ skills: [
30
+ join(home, ".claude", "skills", "mental"),
31
+ join(home, ".cursor", "skills", "mental"),
32
+ join(home, ".agents", "skills", "mental"),
33
+ join(home, ".config", "opencode", "skills", "mental"),
34
+ ],
35
+ cursorRule: join(home, ".cursor", "rules", "mental.mdc"),
36
+ managedDocs: [
37
+ join(home, ".claude", "CLAUDE.md"),
38
+ join(home, ".agents", "AGENTS.md"),
39
+ ],
40
+ };
41
+ }
42
+
43
+ function copySkill(dest) {
44
+ mkdirSync(dirname(dest), { recursive: true });
45
+ let target = dest;
46
+ try {
47
+ const st = lstatSync(dest);
48
+ if (st.isSymbolicLink()) {
49
+ // Balakit-era layout: ~/.claude/skills/mental → ~/.agents/skills/mental
50
+ target = realpathSync(dest);
51
+ } else if (!st.isDirectory()) {
52
+ rmSync(dest);
53
+ }
54
+ } catch {
55
+ // dest does not exist yet
56
+ }
57
+ cpSync(skillSourceDir(), target, { recursive: true, force: true });
58
+ }
59
+
60
+ function copyRule(dest) {
61
+ mkdirSync(dirname(dest), { recursive: true });
62
+ cpSync(ruleSourceFile(), dest);
63
+ }
64
+
65
+ /**
66
+ * Insert or replace a managed HTML-comment block.
67
+ * @param {string} file
68
+ * @param {string} content
69
+ */
70
+ export function mergeManaged(file, content) {
71
+ mkdirSync(dirname(file), { recursive: true });
72
+ const block = `${BEGIN}\n${content.trim()}\n${END}`;
73
+ let cur = "";
74
+ try {
75
+ cur = readFileSync(file, "utf8");
76
+ } catch {
77
+ cur = "";
78
+ }
79
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ if (cur.includes(BEGIN) && cur.includes(END)) {
81
+ const re = new RegExp(`${esc(BEGIN)}[\\s\\S]*?${esc(END)}`);
82
+ writeFileSync(file, cur.replace(re, block));
83
+ return { file, created: false, updated: true };
84
+ }
85
+ const gap = cur && !cur.endsWith("\n") ? "\n" : "";
86
+ const prefix = cur ? `${cur}${gap}\n` : "";
87
+ writeFileSync(file, `${prefix}${block}\n`);
88
+ return { file, created: !existsSync(file) && !cur, updated: true };
89
+ }
90
+
91
+ /**
92
+ * @param {{ home: string, projectDir?: string | null, dryRun?: boolean }} opts
93
+ */
94
+ export function installSkills({ home, projectDir = null, dryRun = false }) {
95
+ const targets = userInstallTargets(home);
96
+ /** @type {string[]} */
97
+ const written = [];
98
+ if (!dryRun) {
99
+ for (const dest of targets.skills) {
100
+ copySkill(dest);
101
+ written.push(dest);
102
+ }
103
+ copyRule(targets.cursorRule);
104
+ written.push(targets.cursorRule);
105
+ for (const doc of targets.managedDocs) {
106
+ mergeManaged(doc, ruleBodyText());
107
+ written.push(doc);
108
+ }
109
+ if (projectDir) {
110
+ const vendored = join(projectDir, ".github", "skills", "mental");
111
+ copySkill(vendored);
112
+ written.push(vendored);
113
+ }
114
+ }
115
+ return { ok: true, written, targets };
116
+ }
117
+
118
+ export function skillsPresent(home) {
119
+ return userInstallTargets(home).skills.some((d) => existsSync(join(d, "SKILL.md")));
120
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Minimal MCP stdio server (JSON-RPC + Content-Length). Default off.
3
+ * Tools wrap the same command handlers as the CLI; agents should still prefer
4
+ * `mental … --json` when they can shell. MCP exists so agents that only speak
5
+ * tools (parallel sessions, orchestrators) can re-pulse and record mid-chat.
6
+ * Also owns `enableMcp`/`disableMcp` — the `install --mcp` config writers.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { cmdWhere } from "../commands/where.mjs";
11
+ import { cmdHeartbeat } from "../commands/heartbeat.mjs";
12
+ import { cmdStatus } from "../commands/status.mjs";
13
+ import { cmdSearch } from "../commands/search.mjs";
14
+ import { cmdShow } from "../commands/show.mjs";
15
+ import { cmdList } from "../commands/list.mjs";
16
+ import { cmdJournal } from "../commands/journal.mjs";
17
+ import { cmdAttention } from "../commands/attention.mjs";
18
+ import { cmdDecide } from "../commands/decide.mjs";
19
+ import { cmdNote } from "../commands/note.mjs";
20
+ import { VERSION, CMD } from "./pkg.mjs";
21
+
22
+ const PROTOCOL = "2024-11-05";
23
+
24
+ const TOOLS = [
25
+ { name: "heartbeat", description: "Cheap pulse: resume, last outcome, git, residue, unsettled decisions. Safe to re-call any time mid-chat.", inputSchema: { type: "object", properties: {} } },
26
+ { name: "where", description: "Active Mental bundle (root, id, mode)", inputSchema: { type: "object", properties: {} } },
27
+ { name: "status", description: "Git + latest Resume + open decisions + notes", inputSchema: { type: "object", properties: {} } },
28
+ {
29
+ name: "search",
30
+ description: "Search OKF concepts (decisions, attention, notes, journal). Structured filters optional; then show a path.",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ q: { type: "string" },
35
+ type: { type: "string", description: "Concept type (Decision, Attention, Note, Journal)" },
36
+ status: { type: "string" },
37
+ tag: { type: "string" },
38
+ kind: { type: "string", description: "Attention kind: direction | concern | thread" },
39
+ },
40
+ required: ["q"],
41
+ },
42
+ },
43
+ {
44
+ name: "list",
45
+ description: "List OKF concepts with typed frontmatter filters (no query). Prefer this over search for status/type/kind.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ type: { type: "string" },
50
+ status: { type: "string" },
51
+ tag: { type: "string" },
52
+ kind: { type: "string", description: "Attention kind: direction | concern | thread" },
53
+ },
54
+ },
55
+ },
56
+ {
57
+ name: "show",
58
+ description: "Read one OKF file relative to the bundle root",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: { path: { type: "string" } },
62
+ required: ["path"],
63
+ },
64
+ },
65
+ {
66
+ name: "journal",
67
+ description: "Append today's journal section (one per task boundary, not per turn)",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ title: { type: "string" },
72
+ body: { type: "string" },
73
+ resume: { type: "string" },
74
+ },
75
+ required: ["title"],
76
+ },
77
+ },
78
+ {
79
+ name: "attention",
80
+ description: "Create or update residue still in the air. Create needs title + kind; update by title or path; close with status resolved.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ title: { type: "string" },
85
+ kind: { type: "string", enum: ["direction", "concern", "thread"] },
86
+ status: { type: "string", enum: ["open", "later", "resolved"] },
87
+ from: { type: "string", description: "Who raised it (e.g. Tom)" },
88
+ body: { type: "string" },
89
+ path: { type: "string", description: "Bundle-relative path of an existing item to update" },
90
+ },
91
+ },
92
+ },
93
+ {
94
+ name: "decide",
95
+ description: "Create or update a decision. Same title updates the existing file (close with status decided).",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ title: { type: "string" },
100
+ status: { type: "string", enum: ["open", "deferred", "decided", "superseded"] },
101
+ description: { type: "string" },
102
+ body: { type: "string" },
103
+ path: { type: "string", description: "Bundle-relative path of an existing decision to update" },
104
+ },
105
+ },
106
+ },
107
+ {
108
+ name: "note",
109
+ description: "Record a durable, non-obvious, repository-specific fact",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ title: { type: "string" },
114
+ status: { type: "string", enum: ["draft", "active", "superseded"] },
115
+ description: { type: "string" },
116
+ body: { type: "string" },
117
+ },
118
+ required: ["title"],
119
+ },
120
+ },
121
+ ];
122
+
123
+ function capture(handler, args) {
124
+ let buf = "";
125
+ const stdout = {
126
+ write(chunk) {
127
+ buf += chunk;
128
+ return true;
129
+ },
130
+ };
131
+ const code = handler({ ...args, json: true }, { stdout });
132
+ let body;
133
+ try {
134
+ body = JSON.parse(buf);
135
+ } catch {
136
+ body = { ok: false, error: { code: "mcp", message: buf || "empty handler output" } };
137
+ }
138
+ return { code, body };
139
+ }
140
+
141
+ function runTool(name, args, ctx) {
142
+ const base = {
143
+ json: true,
144
+ cwd: ctx.cwd,
145
+ home: ctx.home,
146
+ env: ctx.env,
147
+ dir: ctx.dir,
148
+ flags: {},
149
+ rest: [],
150
+ };
151
+ if (name === "heartbeat") return capture(cmdHeartbeat, base);
152
+ if (name === "where") return capture(cmdWhere, base);
153
+ if (name === "status") return capture(cmdStatus, base);
154
+ if (name === "search") {
155
+ return capture(cmdSearch, {
156
+ ...base,
157
+ rest: [String(args.q || "")],
158
+ flags: {
159
+ type: args.type,
160
+ status: args.status,
161
+ tag: args.tag,
162
+ kind: args.kind,
163
+ },
164
+ });
165
+ }
166
+ if (name === "list") {
167
+ return capture(cmdList, {
168
+ ...base,
169
+ flags: {
170
+ type: args.type,
171
+ status: args.status,
172
+ tag: args.tag,
173
+ kind: args.kind,
174
+ },
175
+ });
176
+ }
177
+ if (name === "show") return capture(cmdShow, { ...base, rest: [String(args.path || "")] });
178
+ if (name === "journal") {
179
+ return capture(cmdJournal, {
180
+ ...base,
181
+ flags: {
182
+ title: args.title,
183
+ body: args.body || "",
184
+ resume: args.resume || "Continue. — open loops: none",
185
+ },
186
+ });
187
+ }
188
+ if (name === "attention") {
189
+ return capture(cmdAttention, {
190
+ ...base,
191
+ flags: {
192
+ title: args.title,
193
+ path: args.path,
194
+ kind: args.kind,
195
+ status: args.status,
196
+ from: args.from,
197
+ body: args.body,
198
+ },
199
+ });
200
+ }
201
+ if (name === "decide") {
202
+ return capture(cmdDecide, {
203
+ ...base,
204
+ flags: {
205
+ title: args.title,
206
+ path: args.path,
207
+ status: args.status,
208
+ description: args.description,
209
+ body: args.body,
210
+ },
211
+ });
212
+ }
213
+ if (name === "note") {
214
+ return capture(cmdNote, {
215
+ ...base,
216
+ flags: { title: args.title, status: args.status, description: args.description, body: args.body },
217
+ });
218
+ }
219
+ return { code: 1, body: { ok: false, error: { code: "unknown-tool", message: name } } };
220
+ }
221
+
222
+ function encode(msg) {
223
+ const json = JSON.stringify(msg);
224
+ return `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`;
225
+ }
226
+
227
+ function handle(msg, ctx) {
228
+ if (!msg || typeof msg !== "object") return null;
229
+ const id = msg.id;
230
+ const method = msg.method;
231
+ if (method === "initialize") {
232
+ return {
233
+ jsonrpc: "2.0",
234
+ id,
235
+ result: {
236
+ protocolVersion: PROTOCOL,
237
+ capabilities: { tools: {} },
238
+ serverInfo: { name: CMD, version: VERSION },
239
+ },
240
+ };
241
+ }
242
+ if (method === "notifications/initialized" || method === "initialized") return null;
243
+ if (method === "tools/list") {
244
+ return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
245
+ }
246
+ if (method === "tools/call") {
247
+ const name = msg.params?.name;
248
+ const args = msg.params?.arguments || {};
249
+ const { body } = runTool(name, args, ctx);
250
+ const text = JSON.stringify(body);
251
+ const isError = body.ok === false;
252
+ return {
253
+ jsonrpc: "2.0",
254
+ id,
255
+ result: {
256
+ content: [{ type: "text", text }],
257
+ isError,
258
+ },
259
+ };
260
+ }
261
+ if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
262
+ if (id == null) return null;
263
+ return {
264
+ jsonrpc: "2.0",
265
+ id,
266
+ error: { code: -32601, message: `Method not found: ${method}` },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Serve MCP on the given streams. Resolves when stdin ends.
272
+ * @param {{ cwd?: string, home?: string, env?: NodeJS.ProcessEnv, dir?: string | null, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream }} ctx
273
+ */
274
+ export function serveMcp(ctx = {}) {
275
+ const stdin = ctx.stdin ?? process.stdin;
276
+ const stdout = ctx.stdout ?? process.stdout;
277
+ const rpcCtx = {
278
+ cwd: ctx.cwd ?? process.cwd(),
279
+ home: ctx.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
280
+ env: ctx.env ?? process.env,
281
+ dir: ctx.dir ?? null,
282
+ };
283
+
284
+ return new Promise((resolve) => {
285
+ let buf = Buffer.alloc(0);
286
+ stdin.on("data", (chunk) => {
287
+ buf = Buffer.concat([buf, Buffer.from(chunk)]);
288
+ while (true) {
289
+ const headerEnd = buf.indexOf("\r\n\r\n");
290
+ if (headerEnd < 0) break;
291
+ const header = buf.slice(0, headerEnd).toString("utf8");
292
+ const lenM = header.match(/Content-Length:\s*(\d+)/i);
293
+ if (!lenM) {
294
+ buf = buf.slice(headerEnd + 4);
295
+ continue;
296
+ }
297
+ const len = Number(lenM[1]);
298
+ const start = headerEnd + 4;
299
+ if (buf.length < start + len) break;
300
+ const json = buf.slice(start, start + len).toString("utf8");
301
+ buf = buf.slice(start + len);
302
+ let msg;
303
+ try {
304
+ msg = JSON.parse(json);
305
+ } catch {
306
+ continue;
307
+ }
308
+ const reply = handle(msg, rpcCtx);
309
+ if (reply) stdout.write(encode(reply));
310
+ }
311
+ });
312
+ stdin.on("end", () => resolve(0));
313
+ stdin.on("error", () => resolve(1));
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Cursor user-level MCP config (`mcpServers` at top level).
319
+ * @param {string} home
320
+ */
321
+ export function cursorMcpPath(home) {
322
+ return join(home, ".cursor", "mcp.json");
323
+ }
324
+
325
+ /**
326
+ * Claude Code user-level MCP config (`mcpServers` at top level of ~/.claude.json).
327
+ * @param {string} home
328
+ */
329
+ export function claudeMcpPath(home) {
330
+ return join(home, ".claude.json");
331
+ }
332
+
333
+ function readJson(file, fallback) {
334
+ if (!existsSync(file)) return fallback;
335
+ try {
336
+ return JSON.parse(readFileSync(file, "utf8"));
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ function writeJson(file, data) {
343
+ mkdirSync(dirname(file), { recursive: true });
344
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
345
+ }
346
+
347
+ const MCP_ENTRY = () => ({ command: CMD, args: ["serve"] });
348
+
349
+ /**
350
+ * Register `mental serve` in user-level MCP configs so tool-only agents can
351
+ * reach Mental mid-chat. Upserts the `mental` key (install overrides previous);
352
+ * never touches other servers. Fails open per file with a parse error.
353
+ * @param {string} home
354
+ */
355
+ export function enableMcp(home) {
356
+ /** @type {string[]} */
357
+ const written = [];
358
+ for (const file of [cursorMcpPath(home), claudeMcpPath(home)]) {
359
+ const cfg = readJson(file, {});
360
+ if (!cfg) {
361
+ return { ok: false, error: { code: "mcp-parse", message: `Could not parse ${file}` }, written };
362
+ }
363
+ cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
364
+ cfg.mcpServers[CMD] = MCP_ENTRY();
365
+ writeJson(file, cfg);
366
+ written.push(file);
367
+ }
368
+ return { ok: true, written, server: MCP_ENTRY() };
369
+ }
370
+
371
+ /**
372
+ * Remove only Mental's own MCP entry (identified by `command: "mental"`).
373
+ * @param {string} home
374
+ */
375
+ export function disableMcp(home) {
376
+ /** @type {string[]} */
377
+ const written = [];
378
+ for (const file of [cursorMcpPath(home), claudeMcpPath(home)]) {
379
+ const cfg = readJson(file, null);
380
+ const entry = cfg?.mcpServers?.[CMD];
381
+ if (!entry || entry.command !== CMD) continue;
382
+ delete cfg.mcpServers[CMD];
383
+ writeJson(file, cfg);
384
+ written.push(file);
385
+ }
386
+ return { ok: true, written };
387
+ }
388
+
389
+ export { TOOLS, handle, encode, runTool };