@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,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
|
+
}
|
package/bin/lib/git.mjs
ADDED
|
@@ -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
|
+
}
|