@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,404 @@
1
+ /**
2
+ * UUID bindings: identity survives path change. Origin is a hint, not the id.
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { normalizeOrigin } from "./git.mjs";
8
+
9
+ export const BINDINGS_VERSION = 1;
10
+
11
+ /**
12
+ * @param {string} home
13
+ */
14
+ export function userMentalDir(home) {
15
+ return join(home, ".mental");
16
+ }
17
+
18
+ /**
19
+ * @param {string} home
20
+ */
21
+ export function bindingsPath(home) {
22
+ return join(userMentalDir(home), "bindings.json");
23
+ }
24
+
25
+ /**
26
+ * @param {string} home
27
+ * @param {string} id
28
+ */
29
+ export function projectSliceDir(home, id) {
30
+ return join(userMentalDir(home), "projects", id);
31
+ }
32
+
33
+ /**
34
+ * @param {string} home
35
+ * @returns {{ version: number, bindings: Array<{
36
+ * id: string,
37
+ * name: string,
38
+ * origins: string[],
39
+ * paths: string[],
40
+ * updatedAt: string,
41
+ * }> }}
42
+ */
43
+ export function loadBindings(home) {
44
+ const file = bindingsPath(home);
45
+ if (!existsSync(file)) {
46
+ return { version: BINDINGS_VERSION, bindings: [] };
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(file, "utf8"));
51
+ } catch {
52
+ throw new Error(`Corrupt bindings file: ${file}`);
53
+ }
54
+ if (!parsed || parsed.version !== BINDINGS_VERSION || !Array.isArray(parsed.bindings)) {
55
+ throw new Error(`Unsupported bindings.json in ${file}`);
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ /**
61
+ * @param {string} home
62
+ * @param {{ version: number, bindings: object[] }} data
63
+ */
64
+ export function saveBindings(home, data) {
65
+ const file = bindingsPath(home);
66
+ mkdirSync(dirname(file), { recursive: true });
67
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
68
+ }
69
+
70
+ /**
71
+ * Record that leftover `./.mental` was copied into this project's home slice.
72
+ * @param {string} home
73
+ * @param {string} id
74
+ * @param {string} from
75
+ * @param {{ copied?: string[], now?: string }} [opts]
76
+ */
77
+ export function recordLegacyImport(home, id, from, { copied = [], now = nowIso() } = {}) {
78
+ const data = loadBindings(home);
79
+ const binding = data.bindings.find((b) => b.id === id);
80
+ if (!binding) return false;
81
+ const abs = resolve(from);
82
+ let changed = false;
83
+ if (binding.legacyImportedFrom !== abs) {
84
+ binding.legacyImportedFrom = abs;
85
+ changed = true;
86
+ }
87
+ if (copied.length > 0 || !binding.legacyImportedAt) {
88
+ binding.legacyImportedAt = now;
89
+ changed = true;
90
+ }
91
+ if (changed) {
92
+ binding.updatedAt = now;
93
+ saveBindings(home, data);
94
+ }
95
+ return changed;
96
+ }
97
+
98
+ /**
99
+ * @param {string} home
100
+ * @param {string} id
101
+ * @param {"home" | "local"} store
102
+ */
103
+ export function setBindingStore(home, id, store) {
104
+ const data = loadBindings(home);
105
+ const binding = data.bindings.find((b) => b.id === id);
106
+ if (!binding) return false;
107
+ if (binding.store === store) return false;
108
+ binding.store = store;
109
+ binding.updatedAt = nowIso();
110
+ saveBindings(home, data);
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Read optional `.mental-id` (uuid only) from a directory.
116
+ * @param {string} dir
117
+ * @returns {string | null}
118
+ */
119
+ export function readMentalId(dir) {
120
+ const file = join(dir, ".mental-id");
121
+ if (!existsSync(file)) return null;
122
+ const id = readFileSync(file, "utf8").trim();
123
+ return id || null;
124
+ }
125
+
126
+ /**
127
+ * Write `.mental-id` at git root. Callers must not commit it (doctor ignore).
128
+ * @param {string} dir
129
+ * @param {string} id
130
+ */
131
+ export function writeMentalId(dir, id) {
132
+ writeFileSync(join(dir, ".mental-id"), `${id}\n`);
133
+ }
134
+
135
+ function nowIso() {
136
+ return new Date().toISOString();
137
+ }
138
+
139
+ function matchesPath(binding, absPath) {
140
+ const want = resolve(absPath);
141
+ return (binding.paths || []).some((p) => resolve(p) === want);
142
+ }
143
+
144
+ /**
145
+ * Resolve (and possibly create) a UUID binding for a git worktree.
146
+ *
147
+ * @param {{
148
+ * gitRoot: string,
149
+ * origin?: string | null,
150
+ * upstream?: string | null,
151
+ * home: string,
152
+ * write?: boolean,
153
+ * now?: string,
154
+ * newId?: () => string,
155
+ * }} opts
156
+ * @returns {{
157
+ * ok: true,
158
+ * id: string,
159
+ * created: boolean,
160
+ * reason: string,
161
+ * binding: object,
162
+ * } | {
163
+ * ok: false,
164
+ * code: string,
165
+ * message: string,
166
+ * fromId?: string,
167
+ * }}
168
+ */
169
+ export function resolveOrCreateBinding({
170
+ gitRoot,
171
+ origin = null,
172
+ upstream = null,
173
+ home,
174
+ write = true,
175
+ now = nowIso(),
176
+ newId = () => randomUUID(),
177
+ }) {
178
+ const data = loadBindings(home);
179
+ const bindings = data.bindings;
180
+ const absRoot = resolve(gitRoot);
181
+ const originN = normalizeOrigin(origin);
182
+ const upstreamN = normalizeOrigin(upstream);
183
+ const mentalId = readMentalId(absRoot);
184
+
185
+ const byId = (id) => bindings.find((b) => b.id === id);
186
+
187
+ if (mentalId) {
188
+ const hit = byId(mentalId);
189
+ if (hit) {
190
+ const changed = appendHints(hit, { origin: originN, path: absRoot, now });
191
+ if (write && changed) saveBindings(home, data);
192
+ return { ok: true, id: hit.id, created: false, reason: "matched .mental-id", binding: hit };
193
+ }
194
+ // Stale .mental-id that matches nothing: fall through rather than inventing
195
+ // a binding for a dead uuid (user can remap). Continue other heuristics.
196
+ }
197
+
198
+ if (originN) {
199
+ const hits = bindings.filter((b) => (b.origins || []).includes(originN));
200
+ const here = hits.filter((b) => matchesPath(b, absRoot));
201
+ if (hits.length > 1 && here.length !== 1) {
202
+ return {
203
+ ok: false,
204
+ code: "ambiguous-origin",
205
+ message: `Multiple bindings share origin ${originN}. Run mental remap --to <id>.`,
206
+ };
207
+ }
208
+ if (here.length === 1 || hits.length === 1) {
209
+ const hit = here[0] || hits[0];
210
+ const changed = appendHints(hit, { origin: originN, path: absRoot, now });
211
+ const reclaimed = reclaimPath(data, hit.id, absRoot, now);
212
+ if (write && (changed || reclaimed)) saveBindings(home, data);
213
+ return {
214
+ ok: true,
215
+ id: hit.id,
216
+ created: false,
217
+ reason: here.length === 1 ? `matched origin ${originN} at path` : `matched origin ${originN}`,
218
+ binding: hit,
219
+ };
220
+ }
221
+ }
222
+
223
+ const pathHits = bindings.filter((b) => matchesPath(b, absRoot));
224
+ if (pathHits.length > 1) {
225
+ return {
226
+ ok: false,
227
+ code: "ambiguous-path",
228
+ message: `Multiple bindings share path ${absRoot}. Run mental remap.`,
229
+ };
230
+ }
231
+ if (pathHits.length === 1) {
232
+ const hit = pathHits[0];
233
+ const changed = appendHints(hit, { origin: originN, path: absRoot, now });
234
+ if (write && changed) saveBindings(home, data);
235
+ return {
236
+ ok: true,
237
+ id: hit.id,
238
+ created: false,
239
+ reason: "matched path",
240
+ binding: hit,
241
+ };
242
+ }
243
+
244
+ // Fork heuristic: new origin, but upstream matches an existing origin.
245
+ if (originN && upstreamN && originN !== upstreamN) {
246
+ const upHits = bindings.filter((b) => (b.origins || []).includes(upstreamN));
247
+ if (upHits.length === 1) {
248
+ return {
249
+ ok: false,
250
+ code: "fork",
251
+ message: `Origin ${originN} looks like a fork of ${upHits[0].name} (${upHits[0].id}). Run mental remap --from ${upHits[0].id} or mental split.`,
252
+ fromId: upHits[0].id,
253
+ };
254
+ }
255
+ }
256
+
257
+ if (!write) {
258
+ return {
259
+ ok: true,
260
+ id: null,
261
+ created: false,
262
+ reason: "no binding yet (read-only)",
263
+ binding: null,
264
+ };
265
+ }
266
+
267
+ const id = newId();
268
+ const name = absRoot.split(/[/\\]/).filter(Boolean).pop() || id;
269
+ const binding = {
270
+ id,
271
+ name,
272
+ origins: originN ? [originN] : [],
273
+ paths: [absRoot],
274
+ updatedAt: now,
275
+ };
276
+ bindings.push(binding);
277
+ saveBindings(home, data);
278
+ // Do not write `.mental-id` here: `where` must not drop files into a
279
+ // worktree that may be public. Identity lives in bindings.json; remap
280
+ // (phase 5) may write the optional hint after ignore is in place.
281
+ return {
282
+ ok: true,
283
+ id,
284
+ created: true,
285
+ reason: originN ? `new binding for ${originN}` : "new binding for path",
286
+ binding,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * When origin later matches an existing binding, drop this path from other
292
+ * bindings (git-init-by-path orphan) so identity merges instead of forking.
293
+ * @returns {boolean} whether anything changed
294
+ */
295
+ function reclaimPath(data, keepId, absPath, now) {
296
+ const want = resolve(absPath);
297
+ let changed = false;
298
+ data.bindings = data.bindings.filter((b) => {
299
+ if (b.id === keepId) return true;
300
+ const before = (b.paths || []).length;
301
+ b.paths = (b.paths || []).filter((p) => resolve(p) !== want);
302
+ if (b.paths.length !== before) {
303
+ b.updatedAt = now;
304
+ changed = true;
305
+ }
306
+ if ((b.paths || []).length === 0 && (b.origins || []).length === 0) {
307
+ changed = true;
308
+ return false;
309
+ }
310
+ return true;
311
+ });
312
+ return changed;
313
+ }
314
+
315
+ /**
316
+ * @returns {boolean} whether the binding mutated
317
+ */
318
+ function appendHints(binding, { origin, path, now }) {
319
+ let changed = false;
320
+ if (origin && !(binding.origins || []).includes(origin)) {
321
+ binding.origins = [...(binding.origins || []), origin];
322
+ changed = true;
323
+ }
324
+ if (path) {
325
+ const abs = resolve(path);
326
+ const has = (binding.paths || []).some((p) => resolve(p) === abs);
327
+ if (!has) {
328
+ binding.paths = [...(binding.paths || []), abs];
329
+ changed = true;
330
+ }
331
+ }
332
+ if (changed) binding.updatedAt = now;
333
+ return changed;
334
+ }
335
+
336
+ /**
337
+ * Drop `absPath` from every binding. Used by remap/split.
338
+ * @returns {boolean}
339
+ */
340
+ export function detachPath(home, absPath, { now = nowIso() } = {}) {
341
+ const data = loadBindings(home);
342
+ const want = resolve(absPath);
343
+ let changed = false;
344
+ for (const b of data.bindings) {
345
+ const before = (b.paths || []).length;
346
+ b.paths = (b.paths || []).filter((p) => resolve(p) !== want);
347
+ if (b.paths.length !== before) {
348
+ b.updatedAt = now;
349
+ changed = true;
350
+ }
351
+ }
352
+ if (changed) saveBindings(home, data);
353
+ return changed;
354
+ }
355
+
356
+ /**
357
+ * Point this git root at an existing UUID. Writes `.mental-id`.
358
+ * @param {{ home: string, gitRoot: string, toId: string, origin?: string | null }} opts
359
+ */
360
+ export function remapToBinding({ home, gitRoot, toId, origin = null, now = nowIso() }) {
361
+ const data = loadBindings(home);
362
+ const hit = data.bindings.find((b) => b.id === toId);
363
+ if (!hit) {
364
+ return { ok: false, code: "unknown-id", message: `No binding ${toId}. Run mental remap to list.` };
365
+ }
366
+ const abs = resolve(gitRoot);
367
+ detachPath(home, abs, { now });
368
+ const fresh = loadBindings(home);
369
+ const binding = fresh.bindings.find((b) => b.id === toId);
370
+ appendHints(binding, { origin: normalizeOrigin(origin), path: abs, now });
371
+ saveBindings(home, fresh);
372
+ writeMentalId(abs, toId);
373
+ return { ok: true, id: toId, binding };
374
+ }
375
+
376
+ /**
377
+ * This clone gets a new UUID. Other clones keep the old one.
378
+ * @param {{ home: string, gitRoot: string, origin?: string | null, copyRoot?: string | null, newId?: () => string }} opts
379
+ */
380
+ export function splitBinding({
381
+ home,
382
+ gitRoot,
383
+ origin = null,
384
+ now = nowIso(),
385
+ newId = () => randomUUID(),
386
+ }) {
387
+ const abs = resolve(gitRoot);
388
+ const id = newId();
389
+ const originN = normalizeOrigin(origin);
390
+ detachPath(home, abs, { now });
391
+ const data = loadBindings(home);
392
+ const name = abs.split(/[/\\]/).filter(Boolean).pop() || id;
393
+ const binding = {
394
+ id,
395
+ name,
396
+ origins: originN ? [originN] : [],
397
+ paths: [abs],
398
+ updatedAt: now,
399
+ };
400
+ data.bindings.push(binding);
401
+ saveBindings(home, data);
402
+ writeMentalId(abs, id);
403
+ return { ok: true, id, binding, dest: projectSliceDir(home, id) };
404
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Detect whether this module is the Node process entry point.
3
+ */
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ function sameResolvedPath(a, b) {
8
+ let left = a;
9
+ let right = b;
10
+ try {
11
+ left = realpathSync(a);
12
+ } catch {
13
+ // argv[1] may be missing on disk in tests; keep the raw path.
14
+ }
15
+ try {
16
+ right = realpathSync(b);
17
+ } catch {
18
+ // Keep the raw module path when realpath fails.
19
+ }
20
+ if (process.platform === "win32") {
21
+ return left.toLowerCase() === right.toLowerCase();
22
+ }
23
+ return left === right;
24
+ }
25
+
26
+ /**
27
+ * True when `argv1` points at the CLI module (direct invoke or npm/npx bin shim).
28
+ *
29
+ * @param {string} importMetaUrl
30
+ * @param {string | undefined} [argv1]
31
+ */
32
+ export function isCliEntry(importMetaUrl, argv1 = process.argv[1]) {
33
+ if (!argv1) return false;
34
+ return sameResolvedPath(argv1, fileURLToPath(importMetaUrl));
35
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Git helpers: origin identity, worktree root, lightweight snapshots.
3
+ *
4
+ * Origin is a *hint* for Mental bindings, never the id. Canonical form is
5
+ * `host/owner/repo` (no scheme, no `.git`, no userinfo).
6
+ */
7
+ import { existsSync, statSync } from "node:fs";
8
+ import { dirname, join, parse, resolve } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ /**
12
+ * @param {string} cwd
13
+ * @param {string[]} args
14
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
15
+ */
16
+ export function runGit(cwd, args, { env = process.env } = {}) {
17
+ return spawnSync("git", ["-C", cwd, ...args], {
18
+ encoding: "utf8",
19
+ env,
20
+ });
21
+ }
22
+
23
+ export function gitAvailable() {
24
+ const r = spawnSync("git", ["--version"], { encoding: "utf8" });
25
+ return !r.error && r.status === 0;
26
+ }
27
+
28
+ /**
29
+ * Strip a trailing `.git` (case-insensitive) and slashes from a path segment.
30
+ * @param {string} path
31
+ */
32
+ function stripGitSuffix(path) {
33
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
34
+ }
35
+
36
+ /**
37
+ * Normalize a git remote URL to `host/owner/repo` (or `host/path`).
38
+ * `git@github.com:org/repo.git` ≡ `https://github.com/org/repo`.
39
+ *
40
+ * @param {string | null | undefined} input
41
+ * @returns {string | null}
42
+ */
43
+ export function normalizeOrigin(input) {
44
+ if (input == null) return null;
45
+ let s = String(input).trim();
46
+ if (!s) return null;
47
+
48
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s);
49
+
50
+ // SCP-like: [user@]host:path (not a URL, not a Windows drive)
51
+ if (!hasScheme && !/^[A-Za-z]:[\\/]/.test(s)) {
52
+ const scp = /^(?:[^@]+@)?([^:]+):(.+)$/;
53
+ const m = s.match(scp);
54
+ if (m && m[2] != null && !m[2].startsWith("//")) {
55
+ const host = m[1].toLowerCase();
56
+ const path = stripGitSuffix(m[2].replace(/^\/+/, ""));
57
+ return path ? `${host}/${path}` : host;
58
+ }
59
+ }
60
+
61
+ let urlStr = s;
62
+ if (!hasScheme) urlStr = `https://${s}`;
63
+
64
+ let u;
65
+ try {
66
+ u = new URL(urlStr);
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ const host = (u.hostname || "").toLowerCase();
72
+ if (!host) return null;
73
+ let path = stripGitSuffix((u.pathname || "").replace(/^\/+/, ""));
74
+ return path ? `${host}/${path}` : host;
75
+ }
76
+
77
+ /**
78
+ * Walk from `cwd` to the filesystem root looking for `.git` (dir or file).
79
+ * Prefer `git rev-parse --show-toplevel` when git works (correct for worktrees).
80
+ *
81
+ * @param {string} cwd
82
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
83
+ * @returns {string | null}
84
+ */
85
+ export function findGitRoot(cwd, { env = process.env } = {}) {
86
+ const start = resolve(cwd);
87
+ if (gitAvailable()) {
88
+ const r = runGit(start, ["rev-parse", "--show-toplevel"], { env });
89
+ if (r.status === 0) {
90
+ const top = (r.stdout || "").trim();
91
+ if (top) return resolve(top);
92
+ }
93
+ }
94
+ let dir = start;
95
+ const { root } = parse(dir);
96
+ while (true) {
97
+ const gitPath = join(dir, ".git");
98
+ if (existsSync(gitPath)) {
99
+ try {
100
+ const st = statSync(gitPath);
101
+ if (st.isDirectory() || st.isFile()) return dir;
102
+ } catch {
103
+ // ignore
104
+ }
105
+ }
106
+ if (dir === root) return null;
107
+ const parent = dirname(dir);
108
+ if (parent === dir) return null;
109
+ dir = parent;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * @param {string} cwd
115
+ * @param {string} [name]
116
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
117
+ * @returns {string | null}
118
+ */
119
+ export function getRemoteUrl(cwd, name = "origin", { env = process.env } = {}) {
120
+ const r = runGit(cwd, ["remote", "get-url", name], { env });
121
+ if (r.status !== 0) return null;
122
+ const url = (r.stdout || "").trim();
123
+ return url || null;
124
+ }
125
+
126
+ /**
127
+ * Lightweight git snapshot for `mental status`. Missing git → null fields.
128
+ *
129
+ * @param {string | null} gitRoot
130
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
131
+ */
132
+ export function gitSnapshot(gitRoot, { env = process.env } = {}) {
133
+ if (!gitRoot || !gitAvailable()) {
134
+ return { branch: null, dirty: false, porcelain: "", recent: [] };
135
+ }
136
+ const branchR = runGit(gitRoot, ["rev-parse", "--abbrev-ref", "HEAD"], { env });
137
+ const branch = branchR.status === 0 ? (branchR.stdout || "").trim() || null : null;
138
+ const st = runGit(gitRoot, ["status", "--porcelain"], { env });
139
+ const porcelain = st.status === 0 ? st.stdout || "" : "";
140
+ const log = runGit(gitRoot, ["log", "-5", "--oneline"], { env });
141
+ const recent =
142
+ log.status === 0
143
+ ? (log.stdout || "")
144
+ .split(/\r?\n/)
145
+ .map((l) => l.trim())
146
+ .filter(Boolean)
147
+ : [];
148
+ return { branch, dirty: porcelain.trim().length > 0, porcelain, recent };
149
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * TTY no-args surface: print where you left off, then exit.
3
+ * Not a standing session. UUID / root / index live on `where` and `doctor`.
4
+ */
5
+ import { resolveBundle } from "./resolve.mjs";
6
+ import { gitSnapshot } from "./git.mjs";
7
+ import {
8
+ ATTENTION_HEARTBEAT_CAP,
9
+ latestJournalHandoff,
10
+ listOpenAttention,
11
+ listOpenDecisions,
12
+ localDate,
13
+ } from "./okf.mjs";
14
+ import { brandMark } from "./output.mjs";
15
+
16
+ export { ATTENTION_HEARTBEAT_CAP };
17
+
18
+ /**
19
+ * Home mode without a UUID is `~/.mental/projects` (parent), not a bundle.
20
+ * @param {{ id?: string | null, mode?: string }} where
21
+ */
22
+ export function isBundleRoot(where) {
23
+ if (where.mode === "env" || where.mode === "local" || where.mode === "personal") return true;
24
+ return Boolean(where.id);
25
+ }
26
+
27
+ /**
28
+ * @param {{ date: string, time?: string | null }} when
29
+ * @param {Date} [now]
30
+ */
31
+ export function formatWhen(when, now = new Date()) {
32
+ if (!when?.date) return null;
33
+ const today = localDate(now);
34
+ if (when.date === today) return when.time || "today";
35
+ const [y, m, d] = when.date.split("-").map(Number);
36
+ const then = new Date(y, m - 1, d);
37
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
38
+ const days = Math.round((start.getTime() - then.getTime()) / 86400000);
39
+ if (days === 1) return "yesterday";
40
+ if (days > 1 && days < 14) return `${days}d ago`;
41
+ return when.date;
42
+ }
43
+
44
+ /**
45
+ * @param {object} args
46
+ */
47
+ export function collectHeartbeat(args) {
48
+ const resolved = resolveBundle({
49
+ cwd: args.cwd ?? process.cwd(),
50
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
51
+ env: args.env ?? process.env,
52
+ dir: args.dir ?? null,
53
+ write: false,
54
+ });
55
+ if (!resolved.ok) return { ok: false, error: resolved.error };
56
+
57
+ const where = resolved.data;
58
+ const git = gitSnapshot(where.gitRoot, { env: args.env ?? process.env });
59
+ const root = isBundleRoot(where) ? where.root : null;
60
+ const handoff = root
61
+ ? latestJournalHandoff(root)
62
+ : { resume: null, outcome: null, file: null, when: null, against: null };
63
+ const openDecisions = root ? listOpenDecisions(root) : [];
64
+ const attention = root ? listOpenAttention(root) : [];
65
+
66
+ return {
67
+ ok: true,
68
+ data: {
69
+ git,
70
+ gitRoot: where.gitRoot,
71
+ handoff,
72
+ against: handoff.against ?? null,
73
+ attention,
74
+ openDecisions,
75
+ },
76
+ };
77
+ }
78
+
79
+ function formatAirItem(a) {
80
+ const tag = a.status === "later" ? "later" : a.kind || a.status || "open";
81
+ return ` [${tag}] ${a.title}`;
82
+ }
83
+
84
+ /**
85
+ * @param {Extract<ReturnType<typeof collectHeartbeat>, { ok: true }>["data"]} data
86
+ * @param {Date} [now]
87
+ * @param {NodeJS.ProcessEnv} [env]
88
+ */
89
+ export function formatHeartbeat(data, now = new Date(), env = process.env) {
90
+ const resume =
91
+ data.handoff.resume ||
92
+ "No journal yet — start work, then `mental journal` at the task boundary.";
93
+ const outcome = data.handoff.outcome || "—";
94
+ const stale = data.handoff.when ? formatWhen(data.handoff.when, now) : null;
95
+ const nowLine = stale ? `${outcome} (${stale})` : outcome;
96
+ const gitLine = data.gitRoot
97
+ ? `${data.git.branch || "(unknown)"} ${data.git.dirty ? "(dirty)" : "(clean)"}`
98
+ : "not a git repo";
99
+ const recent = data.git.recent?.[0] ? `\n ${data.git.recent[0]}` : "";
100
+ const against = data.against || data.handoff?.against;
101
+ const attention = data.attention ?? [];
102
+ const shown = attention.slice(0, ATTENTION_HEARTBEAT_CAP);
103
+ const extra =
104
+ attention.length > ATTENTION_HEARTBEAT_CAP
105
+ ? `\n (+${attention.length - ATTENTION_HEARTBEAT_CAP} more)`
106
+ : "";
107
+ const air =
108
+ attention.length === 0 ? " none" : shown.map(formatAirItem).join("\n") + extra;
109
+ const open =
110
+ (data.openDecisions ?? []).length === 0
111
+ ? " none"
112
+ : data.openDecisions.map((d) => ` [${d.status}] ${d.title}`).join("\n");
113
+
114
+ const lines = [`${brandMark(env)} ${resume}`];
115
+ if (against) lines.push(`Against ${against}`);
116
+ lines.push("", `Now ${nowLine}`, `Git ${gitLine}${recent}`, "In the air", air, "Unsettled", open);
117
+ return lines.join("\n");
118
+ }