@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,144 @@
1
+ /**
2
+ * Optional session hooks. Default off. Merge Mental's command into user hook
3
+ * files without deleting other entries. Never enable from `mental install`.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { PKG_ROOT } from "./pkg.mjs";
8
+
9
+ export const HOOK_MARKER = "mental status --json";
10
+ export const HOOK_SCRIPT = join(PKG_ROOT, "hooks", "session-start.sh");
11
+
12
+ function readJson(file, fallback) {
13
+ if (!existsSync(file)) return fallback;
14
+ try {
15
+ return JSON.parse(readFileSync(file, "utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function writeJson(file, data) {
22
+ mkdirSync(dirname(file), { recursive: true });
23
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
24
+ }
25
+
26
+ function isMentalCommand(cmd) {
27
+ return (
28
+ typeof cmd === "string" &&
29
+ (cmd.includes("mental status") || cmd.includes("session-start.sh"))
30
+ );
31
+ }
32
+
33
+ function isMentalEntry(entry) {
34
+ const cmd = typeof entry === "string" ? entry : entry?.command || entry?.hooks?.[0]?.command;
35
+ return isMentalCommand(cmd);
36
+ }
37
+
38
+ /**
39
+ * @param {string} home
40
+ */
41
+ export function cursorHooksPath(home) {
42
+ return join(home, ".cursor", "hooks.json");
43
+ }
44
+
45
+ /**
46
+ * @param {string} home
47
+ */
48
+ export function claudeSettingsPath(home) {
49
+ return join(home, ".claude", "settings.json");
50
+ }
51
+
52
+ /**
53
+ * @param {string} home
54
+ */
55
+ export function enableHooks(home) {
56
+ /** @type {string[]} */
57
+ const written = [];
58
+
59
+ const cursorFile = cursorHooksPath(home);
60
+ const cursor = readJson(cursorFile, { version: 1, hooks: {} });
61
+ if (!cursor) {
62
+ return { ok: false, error: { code: "hooks-parse", message: `Could not parse ${cursorFile}` } };
63
+ }
64
+ cursor.version = cursor.version || 1;
65
+ cursor.hooks = cursor.hooks || {};
66
+ const sessionStart = Array.isArray(cursor.hooks.sessionStart) ? cursor.hooks.sessionStart : [];
67
+ if (!sessionStart.some(isMentalEntry)) {
68
+ sessionStart.push({ command: `${HOOK_SCRIPT}` });
69
+ }
70
+ cursor.hooks.sessionStart = sessionStart;
71
+ writeJson(cursorFile, cursor);
72
+ written.push(cursorFile);
73
+
74
+ const claudeFile = claudeSettingsPath(home);
75
+ const claude = readJson(claudeFile, {});
76
+ if (!claude) {
77
+ return { ok: false, error: { code: "hooks-parse", message: `Could not parse ${claudeFile}` } };
78
+ }
79
+ claude.hooks = claude.hooks || {};
80
+ const session = Array.isArray(claude.hooks.SessionStart) ? claude.hooks.SessionStart : [];
81
+ const hasMental = session.some((block) =>
82
+ (block.hooks || []).some((h) => isMentalCommand(h.command)),
83
+ );
84
+ if (!hasMental) {
85
+ session.push({
86
+ matcher: "",
87
+ hooks: [{ type: "command", command: `${HOOK_SCRIPT}` }],
88
+ });
89
+ }
90
+ claude.hooks.SessionStart = session;
91
+ const compact = Array.isArray(claude.hooks.PreCompact) ? claude.hooks.PreCompact : [];
92
+ const hasCompact = compact.some((block) =>
93
+ (block.hooks || []).some((h) => isMentalCommand(h.command)),
94
+ );
95
+ if (!hasCompact) {
96
+ compact.push({
97
+ matcher: "",
98
+ hooks: [{ type: "command", command: `${HOOK_SCRIPT}` }],
99
+ });
100
+ }
101
+ claude.hooks.PreCompact = compact;
102
+ writeJson(claudeFile, claude);
103
+ written.push(claudeFile);
104
+
105
+ return { ok: true, written, script: HOOK_SCRIPT };
106
+ }
107
+
108
+ /**
109
+ * Remove only Mental hook entries. Leave the rest of the files intact.
110
+ * @param {string} home
111
+ */
112
+ export function disableHooks(home) {
113
+ /** @type {string[]} */
114
+ const written = [];
115
+
116
+ const cursorFile = cursorHooksPath(home);
117
+ const cursor = readJson(cursorFile, null);
118
+ if (cursor?.hooks?.sessionStart) {
119
+ cursor.hooks.sessionStart = cursor.hooks.sessionStart.filter((e) => !isMentalEntry(e));
120
+ writeJson(cursorFile, cursor);
121
+ written.push(cursorFile);
122
+ }
123
+
124
+ const claudeFile = claudeSettingsPath(home);
125
+ const claude = readJson(claudeFile, null);
126
+ if (claude?.hooks) {
127
+ for (const key of ["SessionStart", "PreCompact"]) {
128
+ if (!Array.isArray(claude.hooks[key])) continue;
129
+ claude.hooks[key] = claude.hooks[key]
130
+ .map((block) => {
131
+ if (!Array.isArray(block.hooks)) return block;
132
+ return {
133
+ ...block,
134
+ hooks: block.hooks.filter((h) => !isMentalCommand(h.command)),
135
+ };
136
+ })
137
+ .filter((block) => !Array.isArray(block.hooks) || block.hooks.length > 0);
138
+ }
139
+ writeJson(claudeFile, claude);
140
+ written.push(claudeFile);
141
+ }
142
+
143
+ return { ok: true, written };
144
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Private-by-default git ignore for `.mental/` and `.mental-id`.
3
+ *
4
+ * v1: user global excludes only, via `mental doctor --fix-ignore`.
5
+ * Agents must never edit `.gitignore`. Pattern inspired by Balakit's
6
+ * exclude helpers; comments and product name are Mental's.
7
+ */
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ export const MENTAL_IGNORE_LINE = ".mental/";
14
+ export const MENTAL_ID_IGNORE_LINE = ".mental-id";
15
+ export const MENTAL_IGNORE_COMMENT =
16
+ "# mental: private continuity — never commit";
17
+
18
+ /**
19
+ * @param {string[]} args
20
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
21
+ */
22
+ export function runGitGlobal(args, { env = process.env } = {}) {
23
+ return spawnSync("git", args, { encoding: "utf8", env });
24
+ }
25
+
26
+ export function gitAvailable({ env = process.env } = {}) {
27
+ const r = runGitGlobal(["--version"], { env });
28
+ return !r.error && r.status === 0;
29
+ }
30
+
31
+ export function expandHome(p, home) {
32
+ if (p === "~") return home;
33
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(home, p.slice(2));
34
+ return p;
35
+ }
36
+
37
+ export function defaultExcludesFile(home, env = process.env) {
38
+ const xdg = env.XDG_CONFIG_HOME;
39
+ const base = xdg && xdg.trim() ? xdg : join(home, ".config");
40
+ return join(base, "git", "ignore");
41
+ }
42
+
43
+ /**
44
+ * Resolve the active global excludes file.
45
+ * Honors existing `core.excludesfile` (never overwrites the user's choice).
46
+ */
47
+ export function resolveGlobalExcludesFile({
48
+ create = false,
49
+ home = homedir(),
50
+ env = process.env,
51
+ } = {}) {
52
+ if (!gitAvailable({ env })) return null;
53
+ const configured = (runGitGlobal(["config", "--global", "--get", "core.excludesfile"], { env }).stdout || "").trim();
54
+ if (configured) return { file: expandHome(configured, home), created: false };
55
+ if (!create) return { file: defaultExcludesFile(home, env), created: false, unset: true };
56
+ const file = defaultExcludesFile(home, env);
57
+ runGitGlobal(["config", "--global", "core.excludesfile", file.split("\\").join("/")], { env });
58
+ return { file, created: true };
59
+ }
60
+
61
+ function fileHasLine(cur, line) {
62
+ return cur.split(/\r?\n/).some((l) => l.trim() === line);
63
+ }
64
+
65
+ /**
66
+ * Idempotently append Mental ignore lines to the machine-wide git excludes.
67
+ */
68
+ export function ensureMentalExcluded({ home = homedir(), env = process.env } = {}) {
69
+ const resolved = resolveGlobalExcludesFile({ create: true, home, env });
70
+ if (!resolved) return { ok: false, reason: "git-unavailable" };
71
+ const { file, created } = resolved;
72
+ let cur = "";
73
+ try {
74
+ cur = readFileSync(file, "utf8");
75
+ } catch {
76
+ cur = "";
77
+ }
78
+ const needBundle = !fileHasLine(cur, MENTAL_IGNORE_LINE);
79
+ const needId = !fileHasLine(cur, MENTAL_ID_IGNORE_LINE);
80
+ if (needBundle || needId) {
81
+ mkdirSync(dirname(file), { recursive: true });
82
+ const gap = cur && !cur.endsWith("\n") ? "\n" : "";
83
+ const bits = [MENTAL_IGNORE_COMMENT];
84
+ if (needBundle) bits.push(MENTAL_IGNORE_LINE);
85
+ if (needId) bits.push(MENTAL_ID_IGNORE_LINE);
86
+ writeFileSync(file, `${cur}${gap}${bits.join("\n")}\n`);
87
+ }
88
+ return { ok: true, file, created, appended: needBundle || needId };
89
+ }
90
+
91
+ /**
92
+ * Live check: is `.mental/` ignored in this worktree?
93
+ */
94
+ export function checkMentalIgnored({ cwd, env = process.env } = {}) {
95
+ if (!gitAvailable({ env })) return { ok: false, liveIgnored: null, reason: "git-unavailable" };
96
+ const ci = spawnSync("git", ["-C", cwd, "check-ignore", "-q", "--", ".mental/probe"], {
97
+ encoding: "utf8",
98
+ env,
99
+ });
100
+ if (ci.status === 128) return { ok: false, liveIgnored: null, reason: "not-a-repo" };
101
+ const liveIgnored = ci.status === 0;
102
+ return { ok: liveIgnored, liveIgnored };
103
+ }
104
+
105
+ /**
106
+ * Refuse to create a project-local `./.mental/` unless git will ignore it.
107
+ * @param {string} cwd
108
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
109
+ */
110
+ export function assertLocalIgnorable(cwd, { env = process.env } = {}) {
111
+ const check = checkMentalIgnored({ cwd, env });
112
+ if (check.liveIgnored === true) return { ok: true };
113
+ if (check.reason === "not-a-repo") return { ok: true };
114
+ return {
115
+ ok: false,
116
+ error: {
117
+ code: "not-ignored",
118
+ message:
119
+ "./.mental/ is not gitignored. Run `mental doctor --fix-ignore`, then retry. Mental will not create a project-local bundle that git would track.",
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Ingest leftover Balakit `./.mental` into the home UUID slice.
3
+ *
4
+ * Not a raw copy: files are classified onto canonical OKF paths and
5
+ * frontmatter is normalized (`type`, `title`, `status`, `timestamp`, `tags`).
6
+ * Source is never deleted. Dest files that already exist are not overwritten.
7
+ */
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { parseFrontmatter, slugify, stringifyFrontmatter } from "./okf.mjs";
11
+
12
+ /** Written by `mental local`. Leftover Balakit bundles do not have this. */
13
+ export const LOCAL_STORE_MARKER = ".mental-local";
14
+
15
+ const SKIP_NAMES = new Set([LOCAL_STORE_MARKER, ".DS_Store"]);
16
+ const SKIP_DIR_NAMES = new Set(["status"]);
17
+
18
+ const DEFAULT_STATUS = {
19
+ Note: "active",
20
+ Journal: "active",
21
+ Decision: "decided",
22
+ Attention: "open",
23
+ Status: "active",
24
+ };
25
+
26
+ /**
27
+ * @param {string} dir absolute path to a `.mental` directory
28
+ */
29
+ export function isOptedInLocal(dir) {
30
+ return existsSync(join(dir, LOCAL_STORE_MARKER));
31
+ }
32
+
33
+ /**
34
+ * Mark `./.mental` as the explicit project-local store (`mental local`).
35
+ * @param {string} dir
36
+ */
37
+ export function markOptedInLocal(dir) {
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileSync(join(dir, LOCAL_STORE_MARKER), "local\n");
40
+ }
41
+
42
+ /**
43
+ * @param {string} rel posix relative path from leftover root
44
+ * @returns {{ dest: string, type: string } | null}
45
+ */
46
+ export function classifyLegacyPath(rel) {
47
+ const posix = rel.split("\\").join("/");
48
+ const top = posix.split("/")[0];
49
+ if (SKIP_NAMES.has(top) || SKIP_DIR_NAMES.has(top)) return null;
50
+ if (!posix.endsWith(".md")) return null;
51
+ if (posix === "index.md") return { dest: "index.md", type: "Status" };
52
+ if (posix.startsWith("notes/")) return { dest: posix, type: "Note" };
53
+ if (posix.startsWith("decisions/")) return { dest: posix, type: "Decision" };
54
+ if (posix.startsWith("attention/")) return { dest: posix, type: "Attention" };
55
+ if (posix.startsWith("journal/")) return { dest: posix, type: "Journal" };
56
+ if (posix === "journal.md") return { dest: "journal/imported-root.md", type: "Journal" };
57
+ if (!posix.includes("/")) return { dest: `notes/${posix}`, type: "Note" };
58
+ return { dest: posix, type: "Note" };
59
+ }
60
+
61
+ function titleFrom(data, body, dest) {
62
+ if (data.title) return String(data.title);
63
+ const h = body.match(/^#\s+(.+)$/m);
64
+ if (h) return h[1].trim();
65
+ const base = dest.split("/").pop() || dest;
66
+ return slugify(base.replace(/\.md$/, "")).replace(/-/g, " ");
67
+ }
68
+
69
+ /**
70
+ * @param {string} type
71
+ * @param {Record<string, string | string[]>} data
72
+ * @param {string} body
73
+ * @param {string} dest
74
+ * @param {Date} now
75
+ */
76
+ export function normalizeLegacyMarkdown(type, data, body, dest, now = new Date()) {
77
+ const title = titleFrom(data, body, dest);
78
+ /** @type {Record<string, string | string[] | undefined>} */
79
+ const next = {
80
+ type,
81
+ title,
82
+ description: data.description ? String(data.description) : title,
83
+ tags: Array.isArray(data.tags)
84
+ ? data.tags
85
+ : data.tags
86
+ ? [String(data.tags)]
87
+ : type === "Journal"
88
+ ? ["journal"]
89
+ : type === "Status"
90
+ ? ["index"]
91
+ : [],
92
+ timestamp: data.timestamp ? String(data.timestamp) : now.toISOString(),
93
+ status: String(data.status || DEFAULT_STATUS[type] || "active"),
94
+ };
95
+ for (const [k, v] of Object.entries(data)) {
96
+ if (next[k] == null && v != null) next[k] = v;
97
+ }
98
+ return stringifyFrontmatter(next, body);
99
+ }
100
+
101
+ /**
102
+ * @param {string} src leftover bundle directory
103
+ * @param {string} dest `~/.mental/projects/<uuid>`
104
+ * @param {{ now?: Date }} [opts]
105
+ * @returns {{ from: string, to: string, copied: string[], skipped: number }}
106
+ */
107
+ export function importLegacyBundle(src, dest, { now = new Date() } = {}) {
108
+ const from = resolve(src);
109
+ const to = resolve(dest);
110
+ if (from === to) return { from, to, copied: [], skipped: 0 };
111
+ mkdirSync(to, { recursive: true });
112
+ const files = listMd(from);
113
+ files.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
114
+ /** @type {string[]} */
115
+ const copied = [];
116
+ let skipped = 0;
117
+ const claimed = new Set();
118
+ for (const rel of files) {
119
+ const classified = classifyLegacyPath(rel);
120
+ if (!classified) continue;
121
+ if (claimed.has(classified.dest) || existsSync(join(to, classified.dest))) {
122
+ skipped += 1;
123
+ continue;
124
+ }
125
+ const text = readFileSync(join(from, rel), "utf8");
126
+ const { data, body } = parseFrontmatter(text);
127
+ const type = String(data.type || classified.type);
128
+ const markdown = normalizeLegacyMarkdown(type, data, body, classified.dest, now);
129
+ const destFile = join(to, classified.dest);
130
+ mkdirSync(dirname(destFile), { recursive: true });
131
+ writeFileSync(destFile, markdown);
132
+ copied.push(classified.dest);
133
+ claimed.add(classified.dest);
134
+ }
135
+ return { from, to, copied, skipped };
136
+ }
137
+
138
+ function rank(rel) {
139
+ const p = rel.split("\\").join("/");
140
+ if (p.startsWith("notes/")) return 0;
141
+ if (p.startsWith("decisions/")) return 1;
142
+ if (p.startsWith("attention/")) return 2;
143
+ if (p.startsWith("journal/")) return 3;
144
+ if (p === "index.md") return 4;
145
+ return 4;
146
+ }
147
+
148
+ /**
149
+ * @param {string} dir
150
+ * @returns {string[]} posix relative paths
151
+ */
152
+ function listMd(dir) {
153
+ /** @type {string[]} */
154
+ const out = [];
155
+ walk(dir, dir, out);
156
+ return out;
157
+ }
158
+
159
+ function walk(base, dir, out) {
160
+ let names;
161
+ try {
162
+ names = readdirSync(dir);
163
+ } catch {
164
+ return;
165
+ }
166
+ for (const name of names) {
167
+ const abs = join(dir, name);
168
+ const rel = relative(base, abs).split("\\").join("/");
169
+ const top = rel.split("/")[0];
170
+ if (SKIP_NAMES.has(name) || SKIP_NAMES.has(top) || SKIP_DIR_NAMES.has(top)) continue;
171
+ let st;
172
+ try {
173
+ st = statSync(abs);
174
+ } catch {
175
+ continue;
176
+ }
177
+ if (st.isDirectory()) {
178
+ walk(base, abs, out);
179
+ continue;
180
+ }
181
+ if (name.endsWith(".md")) out.push(rel);
182
+ }
183
+ }