@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
|
@@ -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
|
+
}
|