@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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.cursor-plugin/plugin.json +21 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/assets/logo.svg +19 -0
- package/bin/cli.mjs +135 -0
- package/bin/commands/attention.mjs +139 -0
- package/bin/commands/decide.mjs +104 -0
- package/bin/commands/doctor.mjs +150 -0
- package/bin/commands/heartbeat.mjs +21 -0
- package/bin/commands/hooks.mjs +41 -0
- package/bin/commands/install.mjs +86 -0
- package/bin/commands/journal.mjs +54 -0
- package/bin/commands/link.mjs +18 -0
- package/bin/commands/list.mjs +51 -0
- package/bin/commands/local.mjs +118 -0
- package/bin/commands/note.mjs +61 -0
- package/bin/commands/reindex.mjs +48 -0
- package/bin/commands/remap.mjs +76 -0
- package/bin/commands/search.mjs +55 -0
- package/bin/commands/serve.mjs +16 -0
- package/bin/commands/show.mjs +61 -0
- package/bin/commands/split.mjs +56 -0
- package/bin/commands/status.mjs +136 -0
- package/bin/commands/uninstall.mjs +58 -0
- package/bin/commands/where.mjs +29 -0
- package/bin/lib/args.mjs +117 -0
- package/bin/lib/bindings.mjs +404 -0
- package/bin/lib/entry.mjs +35 -0
- package/bin/lib/git.mjs +149 -0
- package/bin/lib/heartbeat.mjs +118 -0
- package/bin/lib/hooks.mjs +144 -0
- package/bin/lib/ignore.mjs +122 -0
- package/bin/lib/import-legacy.mjs +183 -0
- package/bin/lib/index.mjs +574 -0
- package/bin/lib/install-cli.mjs +100 -0
- package/bin/lib/install-skills.mjs +120 -0
- package/bin/lib/mcp.mjs +389 -0
- package/bin/lib/okf.mjs +746 -0
- package/bin/lib/output.mjs +112 -0
- package/bin/lib/pkg.mjs +22 -0
- package/bin/lib/resolve.mjs +302 -0
- package/bin/lib/uninstall.mjs +56 -0
- package/hooks/session-start.sh +4 -0
- package/mcp.json +11 -0
- package/package.json +43 -0
- package/plugin.json +21 -0
- package/rules/mental.mdc +18 -0
- package/skills/mental/SKILL.md +277 -0
- package/skills/mental/references/templates.md +186 -0
package/bin/lib/okf.mjs
ADDED
|
@@ -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
|
+
}
|