@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,746 @@
1
+ /**
2
+ * OKF markdown SoT: tiny frontmatter parse/write + bundle skeleton.
3
+ * No YAML library — templates only need scalars and `[tag, lists]`.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, cpSync } from "node:fs";
6
+ import { basename, dirname, join, relative, resolve as resolvePath, sep } from "node:path";
7
+
8
+ export const CONCEPT_DIRS = ["journal", "decisions", "notes", "attention", "status"];
9
+
10
+ export const ATTENTION_STATUSES = new Set(["open", "later", "resolved"]);
11
+ export const ATTENTION_KINDS = new Set(["direction", "concern", "thread"]);
12
+ export const ATTENTION_HEARTBEAT_CAP = 7;
13
+ export const DECISION_STATUSES = new Set(["open", "deferred", "decided", "superseded"]);
14
+
15
+ /**
16
+ * @param {Date} [d]
17
+ */
18
+ export function localDate(d = new Date()) {
19
+ const y = d.getFullYear();
20
+ const m = String(d.getMonth() + 1).padStart(2, "0");
21
+ const day = String(d.getDate()).padStart(2, "0");
22
+ return `${y}-${m}-${day}`;
23
+ }
24
+
25
+ /**
26
+ * @param {Date} [d]
27
+ */
28
+ export function localTime(d = new Date()) {
29
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
30
+ }
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {{ data: Record<string, string | string[]>, body: string }}
35
+ */
36
+ export function parseFrontmatter(text) {
37
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
38
+ if (!m) return { data: {}, body: text };
39
+ /** @type {Record<string, string | string[]>} */
40
+ const data = {};
41
+ for (const line of m[1].split(/\r?\n/)) {
42
+ const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*?)\s*$/);
43
+ if (!kv) continue;
44
+ let v = kv[2];
45
+ const hash = v.indexOf(" #");
46
+ if (hash >= 0) v = v.slice(0, hash).trim();
47
+ if (v.startsWith("[") && v.endsWith("]")) {
48
+ data[kv[1]] = v
49
+ .slice(1, -1)
50
+ .split(",")
51
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
52
+ .filter(Boolean);
53
+ } else {
54
+ data[kv[1]] = v.replace(/^["']|["']$/g, "");
55
+ }
56
+ }
57
+ return { data, body: m[2] };
58
+ }
59
+
60
+ /**
61
+ * @param {Record<string, string | string[] | undefined>} data
62
+ * @param {string} body
63
+ */
64
+ export function stringifyFrontmatter(data, body) {
65
+ const lines = ["---"];
66
+ for (const [k, v] of Object.entries(data)) {
67
+ if (v == null) continue;
68
+ if (Array.isArray(v)) lines.push(`${k}: [${v.join(", ")}]`);
69
+ else lines.push(`${k}: ${v}`);
70
+ }
71
+ lines.push("---", "");
72
+ const b = body.startsWith("\n") ? body.replace(/^\n+/, "") : body;
73
+ return `${lines.join("\n")}${b.endsWith("\n") ? b : `${b}\n`}`;
74
+ }
75
+
76
+ function slugify(s) {
77
+ return s
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "-")
80
+ .replace(/^-|-$/g, "")
81
+ .slice(0, 60) || "untitled";
82
+ }
83
+
84
+ export { slugify };
85
+
86
+ /**
87
+ * Repo-relative pointer (plan file, dump). Rejects `..` and absolute paths.
88
+ * @param {string | undefined} raw
89
+ * @returns {string | null | undefined} undefined if omitted, null if invalid
90
+ */
91
+ export function repoRelativePath(raw) {
92
+ if (raw == null || raw === "") return undefined;
93
+ const s = String(raw).replace(/\\/g, "/").trim();
94
+ if (s.startsWith("/") || /^[A-Za-z]:/.test(s)) return null;
95
+ const parts = s.split("/").filter((p) => p && p !== ".");
96
+ if (parts.length === 0 || parts.some((p) => p === ".." || p.includes("\0"))) return null;
97
+ return parts.join("/");
98
+ }
99
+
100
+ /**
101
+ * @param {string} root
102
+ * @param {{ name?: string, now?: Date }} [opts]
103
+ */
104
+ export function ensureSkeleton(root, { name = "project", now = new Date() } = {}) {
105
+ mkdirSync(root, { recursive: true });
106
+ for (const d of CONCEPT_DIRS) mkdirSync(join(root, d), { recursive: true });
107
+ const ts = now.toISOString();
108
+ const date = localDate(now);
109
+ const indexPath = join(root, "index.md");
110
+ if (!existsSync(indexPath)) {
111
+ writeFileSync(
112
+ indexPath,
113
+ stringifyFrontmatter(
114
+ {
115
+ type: "Status",
116
+ title: `${name} — .mental index`,
117
+ description: "Entry point and navigation for this .mental bundle.",
118
+ tags: ["index"],
119
+ timestamp: ts,
120
+ status: "active",
121
+ },
122
+ `# ${name} — mental index
123
+
124
+ Private continuity log. Start at [current status](status/current.md).
125
+
126
+ - [Status](status/current.md) — disposable snapshot derived from live evidence
127
+ - [Journal](journal/) — concise outcomes and exact handoffs
128
+ - [Decisions](decisions/) — consequential choices and rationale
129
+ - [Attention](attention/) — residue still in the air after a hop
130
+ - [Notes](notes/) — durable facts that prevent repeat investigation
131
+ `,
132
+ ),
133
+ );
134
+ }
135
+ const statusPath = join(root, "status", "current.md");
136
+ if (!existsSync(statusPath)) {
137
+ writeFileSync(statusPath, renderStatus({ name, date, ts, now: "Not yet derived.", inFlight: "None", decisions: [], resume: "Run `mental status` after the first journal entry." }));
138
+ }
139
+ return root;
140
+ }
141
+
142
+ /**
143
+ * @param {{
144
+ * name: string,
145
+ * date: string,
146
+ * ts: string,
147
+ * now: string,
148
+ * inFlight: string,
149
+ * decisions: Array<{ title: string, file: string, status: string, awaits?: string }>,
150
+ * attention?: Array<{ title: string, file: string, status: string, kind?: string }>,
151
+ * notes?: Array<{ title: string, file: string, status: string, description?: string }>,
152
+ * resume: string,
153
+ * against?: string | null,
154
+ * }} opts
155
+ */
156
+ export function renderStatus({ name, date, ts, now, inFlight, decisions, attention = [], notes = [], resume, against = null }) {
157
+ const decLines =
158
+ decisions.length === 0
159
+ ? "- None"
160
+ : decisions.map((d) => {
161
+ const extra = d.status === "deferred" && d.awaits ? `: ${d.awaits}` : "";
162
+ return `- [${d.title}](../decisions/${d.file}) — ${d.status}${extra}`;
163
+ }).join("\n");
164
+ const shownAttention = attention.slice(0, ATTENTION_HEARTBEAT_CAP);
165
+ const extraAir =
166
+ attention.length > ATTENTION_HEARTBEAT_CAP
167
+ ? `\n- (+${attention.length - ATTENTION_HEARTBEAT_CAP} more)`
168
+ : "";
169
+ const airLines =
170
+ shownAttention.length === 0
171
+ ? "- None"
172
+ : shownAttention
173
+ .map((a) => {
174
+ const tag = a.status === "later" ? "later" : a.kind || a.status;
175
+ return `- [${a.title}](../attention/${a.file}) — ${tag}`;
176
+ })
177
+ .join("\n") + extraAir;
178
+ const noteLines =
179
+ notes.length === 0
180
+ ? "- None"
181
+ : notes
182
+ .map((n) => {
183
+ const extra = n.description ? ` — ${n.description}` : "";
184
+ return `- [${n.title}](../notes/${n.file})${extra}`;
185
+ })
186
+ .join("\n");
187
+ const againstLine = against ? `\nAgainst ${against}\n` : "";
188
+ return stringifyFrontmatter(
189
+ {
190
+ type: "Status",
191
+ title: "Current status",
192
+ description: 'Derived "you are here" snapshot — regenerate, don\'t hand-edit.',
193
+ tags: ["status"],
194
+ timestamp: ts,
195
+ status: "active",
196
+ },
197
+ `# Status — ${name}
198
+ _Derived ${date} from journal tail + git + residue + decisions + notes. Stale? Re-derive._
199
+
200
+ ## Now
201
+ ${now}
202
+
203
+ ## In flight
204
+ ${inFlight}
205
+ ${againstLine}
206
+ ## In the air
207
+ ${airLines}
208
+
209
+ ## Unsettled
210
+ ${decLines}
211
+
212
+ ## Notes
213
+ ${noteLines}
214
+
215
+ ## ▶ Resume point
216
+ ${resume}
217
+ `,
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Latest journal `Resume:` line and the heading of that section.
223
+ * @param {string} root
224
+ */
225
+ export function latestJournalHandoff(root) {
226
+ const empty = { resume: null, outcome: null, file: null, when: null, against: null };
227
+ const dir = join(root, "journal");
228
+ if (!existsSync(dir)) return empty;
229
+ const all = readdirSync(dir).filter((f) => f.endsWith(".md"));
230
+ const dated = all.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort();
231
+ const files = dated.length ? dated : all.sort();
232
+ if (files.length === 0) return empty;
233
+ const file = files[files.length - 1];
234
+ const text = readFileSync(join(dir, file), "utf8");
235
+ const { body } = parseFrontmatter(text);
236
+ const sections = body.split(/^## /m).filter(Boolean);
237
+ const last = sections[sections.length - 1] || body;
238
+ const resumeM = last.match(/^Resume:\s*(.+)$/m) || body.match(/^Resume:\s*(.+)$/m);
239
+ const againstM = last.match(/^Against:\s*(.+)$/m) || last.match(/^Plan:\s*(.+)$/m);
240
+ const headingM = last.match(/^([^\n]+)/);
241
+ const heading = headingM ? headingM[1] : "";
242
+ const outcome = heading.replace(/^\d{1,2}:\d{2}\s+—\s+/, "").trim() || null;
243
+ const date = file.match(/^(\d{4}-\d{2}-\d{2})\.md$/)?.[1] ?? null;
244
+ const timeM = heading.match(/^(\d{1,2}:\d{2})/);
245
+ return {
246
+ resume: resumeM ? resumeM[1].trim() : null,
247
+ outcome,
248
+ file: `journal/${file}`,
249
+ when: date ? { date, time: timeM ? timeM[1] : null } : null,
250
+ against: againstM ? againstM[1].trim() : null,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Newest journal sections first (dated files only). One entry per `## ` heading.
256
+ *
257
+ * @param {string} root
258
+ * @param {number} [limit]
259
+ * @returns {Array<{ path: string, file: string, heading: string, title: string }>}
260
+ */
261
+ export function recentJournalSections(root, limit = 8) {
262
+ const dir = join(root, "journal");
263
+ if (!existsSync(dir)) return [];
264
+ const dated = readdirSync(dir)
265
+ .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
266
+ .sort()
267
+ .reverse();
268
+ /** @type {ReturnType<typeof recentJournalSections>} */
269
+ const out = [];
270
+ for (const file of dated) {
271
+ if (out.length >= limit) break;
272
+ let text;
273
+ try {
274
+ text = readFileSync(join(dir, file), "utf8");
275
+ } catch {
276
+ continue;
277
+ }
278
+ const { body } = parseFrontmatter(text);
279
+ const sections = body.split(/^## /m).filter(Boolean);
280
+ for (let i = sections.length - 1; i >= 0; i--) {
281
+ if (out.length >= limit) break;
282
+ const heading = (sections[i].split(/\r?\n/, 1)[0] || "").trim();
283
+ if (!heading) continue;
284
+ const title = heading.replace(/^\d{1,2}:\d{2}\s+—\s+/, "").trim() || heading;
285
+ out.push({ path: `journal/${file}`, file, heading, title });
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * @param {string} root
293
+ */
294
+ export function listOpenDecisions(root) {
295
+ const dir = join(root, "decisions");
296
+ if (!existsSync(dir)) return [];
297
+ const out = [];
298
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
299
+ const text = readFileSync(join(dir, file), "utf8");
300
+ const { data } = parseFrontmatter(text);
301
+ const status = String(data.status || "");
302
+ if (status === "open" || status === "deferred") {
303
+ out.push({
304
+ path: `decisions/${file}`,
305
+ file,
306
+ title: String(data.title || basename(file, ".md")),
307
+ status,
308
+ description: data.description ? String(data.description) : "",
309
+ });
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * @param {string} root
317
+ * @param {{ path?: string, title?: string }} opts
318
+ * @returns {{ path: string, file: string, title: string, status: string, data: Record<string, string | string[]>, body: string } | null}
319
+ */
320
+ export function findDecision(root, { path, title } = {}) {
321
+ const dir = join(root, "decisions");
322
+ if (!existsSync(dir)) return null;
323
+ if (path) {
324
+ const rel = String(path).replace(/\\/g, "/").replace(/^\/+/, "");
325
+ const got = readBundleFile(root, rel);
326
+ if (!got.ok) return null;
327
+ if (!got.data.path.startsWith("decisions/") || !got.data.path.endsWith(".md")) return null;
328
+ const d = got.data.data;
329
+ return {
330
+ path: got.data.path,
331
+ file: basename(got.data.path),
332
+ title: String(d.title || basename(got.data.path, ".md")),
333
+ status: String(d.status || "open"),
334
+ data: d,
335
+ body: got.data.body,
336
+ };
337
+ }
338
+ if (!title) return null;
339
+ const matches = [];
340
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
341
+ const text = readFileSync(join(dir, file), "utf8");
342
+ const parsed = parseFrontmatter(text);
343
+ const t = String(parsed.data.title || basename(file, ".md"));
344
+ if (t === title) {
345
+ matches.push({
346
+ path: `decisions/${file}`,
347
+ file,
348
+ title: t,
349
+ status: String(parsed.data.status || "open"),
350
+ data: parsed.data,
351
+ body: parsed.body,
352
+ });
353
+ }
354
+ }
355
+ matches.sort((a, b) => b.file.localeCompare(a.file));
356
+ return matches[0] ?? null;
357
+ }
358
+
359
+ /**
360
+ * One-line blurb for status JSON: frontmatter description, else first body paragraph.
361
+ * @param {Record<string, string | string[]>} data
362
+ * @param {string} body
363
+ */
364
+ function noteBlurb(data, body) {
365
+ if (data.description) return String(data.description);
366
+ const stripped = body.replace(/^#\s+.+$/m, "").trim();
367
+ const para = stripped.split(/\n\s*\n/)[0] || stripped;
368
+ return para.replace(/\s+/g, " ").trim().slice(0, 240);
369
+ }
370
+
371
+ /**
372
+ * Active/draft notes in the bundle. Superseded files stay on disk but are omitted.
373
+ * Missing `status` (Balakit-era notes) counts as `active`.
374
+ * @param {string} root
375
+ */
376
+ export function listNotes(root) {
377
+ const dir = join(root, "notes");
378
+ if (!existsSync(dir)) return [];
379
+ const out = [];
380
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
381
+ const text = readFileSync(join(dir, file), "utf8");
382
+ const { data, body } = parseFrontmatter(text);
383
+ const status = String(data.status || "active");
384
+ if (status === "superseded") continue;
385
+ out.push({
386
+ path: `notes/${file}`,
387
+ file,
388
+ title: String(data.title || basename(file, ".md")),
389
+ status,
390
+ description: noteBlurb(data, body),
391
+ });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * Open or later attention (residue). Newest filename first. Resolved files stay on disk.
398
+ * @param {string} root
399
+ */
400
+ export function listOpenAttention(root) {
401
+ const dir = join(root, "attention");
402
+ if (!existsSync(dir)) return [];
403
+ const out = [];
404
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse()) {
405
+ const text = readFileSync(join(dir, file), "utf8");
406
+ const { data, body } = parseFrontmatter(text);
407
+ const status = String(data.status || "open");
408
+ if (status !== "open" && status !== "later") continue;
409
+ out.push({
410
+ path: `attention/${file}`,
411
+ file,
412
+ title: String(data.title || basename(file, ".md")),
413
+ status,
414
+ kind: data.kind ? String(data.kind) : "",
415
+ from: data.from ? String(data.from) : "",
416
+ against: data.against ? String(data.against) : "",
417
+ description: noteBlurb(data, body),
418
+ });
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * @param {string} root
425
+ * @param {{ path?: string, title?: string }} opts
426
+ * @returns {{ path: string, file: string, title: string, status: string, kind: string, from: string, against: string, data: Record<string, string | string[]>, body: string } | null}
427
+ */
428
+ export function findAttention(root, { path, title } = {}) {
429
+ const dir = join(root, "attention");
430
+ if (!existsSync(dir)) return null;
431
+ if (path) {
432
+ const rel = String(path).replace(/\\/g, "/").replace(/^\/+/, "");
433
+ const got = readBundleFile(root, rel);
434
+ if (!got.ok) return null;
435
+ if (!got.data.path.startsWith("attention/") || !got.data.path.endsWith(".md")) return null;
436
+ const d = got.data.data;
437
+ return {
438
+ path: got.data.path,
439
+ file: basename(got.data.path),
440
+ title: String(d.title || basename(got.data.path, ".md")),
441
+ status: String(d.status || "open"),
442
+ kind: d.kind ? String(d.kind) : "",
443
+ from: d.from ? String(d.from) : "",
444
+ against: d.against ? String(d.against) : "",
445
+ data: d,
446
+ body: got.data.body,
447
+ };
448
+ }
449
+ if (!title) return null;
450
+ const matches = [];
451
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
452
+ const text = readFileSync(join(dir, file), "utf8");
453
+ const parsed = parseFrontmatter(text);
454
+ const t = String(parsed.data.title || basename(file, ".md"));
455
+ if (t === title) {
456
+ matches.push({
457
+ path: `attention/${file}`,
458
+ file,
459
+ title: t,
460
+ status: String(parsed.data.status || "open"),
461
+ kind: parsed.data.kind ? String(parsed.data.kind) : "",
462
+ from: parsed.data.from ? String(parsed.data.from) : "",
463
+ against: parsed.data.against ? String(parsed.data.against) : "",
464
+ data: parsed.data,
465
+ body: parsed.body,
466
+ });
467
+ }
468
+ }
469
+ matches.sort((a, b) => b.file.localeCompare(a.file));
470
+ return matches[0] ?? null;
471
+ }
472
+
473
+ /**
474
+ * @param {string} root
475
+ * @param {{ title: string, status?: string, kind: string, from?: string, against?: string, description?: string, body?: string, slug?: string, now?: Date }} opts
476
+ */
477
+ export function writeAttention(root, { title, status = "open", kind, from, against, description = "", body = "", slug, now = new Date() }) {
478
+ ensureSkeleton(root);
479
+ const day = localDate(now);
480
+ const s = slug || slugify(title);
481
+ const rel = `attention/${day}-${s}.md`;
482
+ const file = join(root, rel);
483
+ mkdirSync(dirname(file), { recursive: true });
484
+ if (existsSync(file)) throw Object.assign(new Error(`Attention already exists: ${rel}`), { code: "exists" });
485
+ const ts = now.toISOString();
486
+ const text = body.trim() || "<why this would cost a reload if forgotten>";
487
+ writeFileSync(
488
+ file,
489
+ stringifyFrontmatter(
490
+ {
491
+ type: "Attention",
492
+ title,
493
+ description: description || title,
494
+ tags: [],
495
+ timestamp: ts,
496
+ status,
497
+ kind,
498
+ from: from || undefined,
499
+ against: against || undefined,
500
+ },
501
+ `# ${title}
502
+
503
+ ${text}
504
+ `,
505
+ ),
506
+ );
507
+ return { path: rel, updated: false };
508
+ }
509
+
510
+ /**
511
+ * @param {string} root
512
+ * @param {string} rel
513
+ * @param {{ title?: string, status?: string, kind?: string, from?: string, against?: string, description?: string, body?: string, now?: Date }} opts
514
+ */
515
+ export function updateAttention(root, rel, { title, status, kind, from, against, description, body, now = new Date() }) {
516
+ const got = readBundleFile(root, rel);
517
+ if (!got.ok) {
518
+ throw Object.assign(new Error(got.error.message), { code: got.error.code });
519
+ }
520
+ const data = { ...got.data.data };
521
+ if (title) data.title = title;
522
+ if (status) data.status = status;
523
+ if (kind) data.kind = kind;
524
+ if (from != null && from !== "") data.from = from;
525
+ if (against != null && against !== "") data.against = against;
526
+ if (description) data.description = description;
527
+ data.timestamp = now.toISOString();
528
+ data.type = "Attention";
529
+ let nextBody = got.data.body;
530
+ if (body != null && body !== "") {
531
+ const heading = String(data.title || title || "Attention");
532
+ nextBody = `# ${heading}\n\n${body.trim()}\n`;
533
+ } else if (title) {
534
+ nextBody = got.data.body.replace(/^#\s+.+$/m, `# ${title}`);
535
+ }
536
+ writeFileSync(got.data.abs, stringifyFrontmatter(data, nextBody));
537
+ return { path: rel, updated: true };
538
+ }
539
+
540
+ /**
541
+ * @param {string} root
542
+ * @param {{ title: string, body?: string, resume?: string, against?: string, now?: Date }} opts
543
+ */
544
+ export function appendJournal(root, { title, body = "", resume = "Continue. — open loops: none", against, now = new Date() }) {
545
+ ensureSkeleton(root);
546
+ const day = localDate(now);
547
+ const file = join(root, "journal", `${day}.md`);
548
+ const ts = now.toISOString();
549
+ const time = localTime(now);
550
+ const againstLine = against ? `\nAgainst: ${against}\n` : "";
551
+ const section = `## ${time} — ${title}
552
+ ${body.trim()}
553
+ ${againstLine}
554
+ Resume: ${resume}
555
+ `;
556
+ if (!existsSync(file)) {
557
+ writeFileSync(
558
+ file,
559
+ stringifyFrontmatter(
560
+ {
561
+ type: "Journal",
562
+ title: `Journal — ${day}`,
563
+ description: `Work log for ${day}.`,
564
+ tags: ["journal"],
565
+ timestamp: ts,
566
+ status: "active",
567
+ },
568
+ `# ${day}
569
+
570
+ ${section}`,
571
+ ),
572
+ );
573
+ } else {
574
+ const cur = readFileSync(file, "utf8");
575
+ const { data, body: existing } = parseFrontmatter(cur);
576
+ data.timestamp = ts;
577
+ const nextBody = `${existing.replace(/\s*$/, "")}\n\n${section}`;
578
+ writeFileSync(file, stringifyFrontmatter(data, nextBody));
579
+ }
580
+ return { path: `journal/${day}.md`, section, against: against ?? null };
581
+ }
582
+
583
+ /**
584
+ * @param {string} root
585
+ * @param {{ title: string, status?: string, description?: string, body?: string, slug?: string, now?: Date }} opts
586
+ */
587
+ export function writeDecision(root, { title, status = "open", description = "", body = "", slug, now = new Date() }) {
588
+ ensureSkeleton(root);
589
+ const day = localDate(now);
590
+ const s = slug || slugify(title);
591
+ const rel = `decisions/${day}-${s}.md`;
592
+ const file = join(root, rel);
593
+ mkdirSync(dirname(file), { recursive: true });
594
+ if (existsSync(file)) throw Object.assign(new Error(`Decision already exists: ${rel}`), { code: "exists" });
595
+ const ts = now.toISOString();
596
+ const defaultBody = `## Context
597
+ ${body.trim() || "<why this choice matters>"}
598
+
599
+ ## Options
600
+ - <option A> — <tradeoff>
601
+ - <option B> — <tradeoff>
602
+
603
+ ## Outcome
604
+ <For open: what input is needed. For deferred: what it awaits. For decided: what was chosen, why, and when.>
605
+ `;
606
+ writeFileSync(
607
+ file,
608
+ stringifyFrontmatter(
609
+ {
610
+ type: "Decision",
611
+ title,
612
+ description: description || title,
613
+ tags: [],
614
+ timestamp: ts,
615
+ status,
616
+ },
617
+ `# ${title}
618
+
619
+ ${defaultBody}`,
620
+ ),
621
+ );
622
+ return { path: rel, updated: false };
623
+ }
624
+
625
+ /**
626
+ * @param {string} root
627
+ * @param {string} rel
628
+ * @param {{ title?: string, status?: string, description?: string, body?: string, now?: Date }} opts
629
+ */
630
+ export function updateDecision(root, rel, { title, status, description, body, now = new Date() }) {
631
+ const got = readBundleFile(root, rel);
632
+ if (!got.ok) {
633
+ throw Object.assign(new Error(got.error.message), { code: got.error.code });
634
+ }
635
+ const data = { ...got.data.data };
636
+ if (title) data.title = title;
637
+ if (status) data.status = status;
638
+ if (description) data.description = description;
639
+ data.timestamp = now.toISOString();
640
+ data.type = "Decision";
641
+ let nextBody = got.data.body;
642
+ if (body != null && body !== "") {
643
+ const heading = String(data.title || title || "Decision");
644
+ nextBody = `# ${heading}\n\n${body.trim()}\n`;
645
+ } else if (title) {
646
+ nextBody = got.data.body.replace(/^#\s+.+$/m, `# ${title}`);
647
+ }
648
+ writeFileSync(got.data.abs, stringifyFrontmatter(data, nextBody));
649
+ return { path: rel, updated: true };
650
+ }
651
+
652
+ /**
653
+ * @param {string} root
654
+ * @param {{ title: string, status?: string, description?: string, body?: string, slug?: string, now?: Date }} opts
655
+ */
656
+ export function writeNote(root, { title, status = "active", description = "", body = "", slug, now = new Date() }) {
657
+ ensureSkeleton(root);
658
+ const s = slug || slugify(title);
659
+ const rel = `notes/${s}.md`;
660
+ const file = join(root, rel);
661
+ mkdirSync(dirname(file), { recursive: true });
662
+ if (existsSync(file)) throw Object.assign(new Error(`Note already exists: ${rel}`), { code: "exists" });
663
+ const ts = now.toISOString();
664
+ writeFileSync(
665
+ file,
666
+ stringifyFrontmatter(
667
+ {
668
+ type: "Note",
669
+ title,
670
+ description: description || title,
671
+ tags: [],
672
+ timestamp: ts,
673
+ status,
674
+ },
675
+ `# ${title}
676
+
677
+ ${body.trim() || "<durable, non-obvious, repository-specific fact>"}
678
+ `,
679
+ ),
680
+ );
681
+ return { path: rel };
682
+ }
683
+
684
+ export function bundleName(root, fallback = "project") {
685
+ const index = join(root, "index.md");
686
+ if (existsSync(index)) {
687
+ const { data } = parseFrontmatter(readFileSync(index, "utf8"));
688
+ if (data.title) return String(data.title).replace(/\s+—\s+\.mental index$/i, "");
689
+ }
690
+ return basename(root) === ".mental" ? basename(dirname(root)) : fallback;
691
+ }
692
+
693
+ /**
694
+ * Read one OKF file relative to the bundle root. Rejects `..` and absolute paths.
695
+ *
696
+ * @param {string} root
697
+ * @param {string} relPath
698
+ * @returns {{ ok: true, data: { path: string, abs: string, text: string, data: Record<string, string | string[]>, body: string } } | { ok: false, error: { code: string, message: string } }}
699
+ */
700
+ export function readBundleFile(root, relPath) {
701
+ const rel = String(relPath || "")
702
+ .replace(/\\/g, "/")
703
+ .replace(/^\/+/, "")
704
+ .trim();
705
+ if (!rel || rel.includes("\0") || rel.split("/").some((p) => p === ".." || p === "")) {
706
+ return { ok: false, error: { code: "path", message: "path must be a relative file inside the bundle" } };
707
+ }
708
+ const abs = resolvePath(root, rel);
709
+ const rootAbs = resolvePath(root);
710
+ const relToRoot = relative(rootAbs, abs);
711
+ if (!relToRoot || relToRoot.startsWith("..") || relToRoot.startsWith(`..${sep}`)) {
712
+ return { ok: false, error: { code: "path", message: "path escapes the bundle root" } };
713
+ }
714
+ if (!existsSync(abs)) {
715
+ return { ok: false, error: { code: "not-found", message: `no such file: ${rel}` } };
716
+ }
717
+ let text;
718
+ try {
719
+ text = readFileSync(abs, "utf8");
720
+ } catch (err) {
721
+ const message = err instanceof Error ? err.message : String(err);
722
+ return { ok: false, error: { code: "read", message } };
723
+ }
724
+ const parsed = parseFrontmatter(text);
725
+ return { ok: true, data: { path: rel, abs, text, data: parsed.data, body: parsed.body } };
726
+ }
727
+
728
+ /**
729
+ * Copy OKF files from one bundle root to another. Skips disposable `status/`.
730
+ * @param {string} src
731
+ * @param {string} dest
732
+ * @param {{ skip?: Set<string> }} [opts]
733
+ * @returns {string[]} copied top-level names
734
+ */
735
+ export function copyOkfTree(src, dest, { skip = new Set(["status"]) } = {}) {
736
+ mkdirSync(dest, { recursive: true });
737
+ if (!existsSync(src)) return [];
738
+ /** @type {string[]} */
739
+ const copied = [];
740
+ for (const name of readdirSync(src)) {
741
+ if (skip.has(name) || name === ".mental-local") continue;
742
+ cpSync(join(src, name), join(dest, name), { recursive: true, force: true });
743
+ copied.push(name);
744
+ }
745
+ return copied;
746
+ }