@tekmidian/pai 0.31.0 → 0.32.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/dist/cli/index.mjs +1 -1
- package/dist/cli/program.mjs +1 -1
- package/dist/{pick-Bmz718uo.mjs → pick-DlsM0ppq.mjs} +500 -20
- package/dist/pick-DlsM0ppq.mjs.map +1 -0
- package/docs/commands/README.md +4 -0
- package/docs/commands/project.md +38 -0
- package/docs/commands/projects.md +38 -0
- package/package.json +1 -1
- package/dist/pick-Bmz718uo.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import "../helpers-crDEr6S2.mjs";
|
|
|
7
7
|
import "../sync--BoxBBok.mjs";
|
|
8
8
|
import "../embeddings-Bn86ssxR.mjs";
|
|
9
9
|
import "../search-CpTv1I24.mjs";
|
|
10
|
-
import "../pick-
|
|
10
|
+
import "../pick-DlsM0ppq.mjs";
|
|
11
11
|
import "../checkpoint-block-DYq9RmMU.mjs";
|
|
12
12
|
import "../indexer-AEcT8wHf.mjs";
|
|
13
13
|
import "../ipc-client-aVKVERjJ.mjs";
|
package/dist/cli/program.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import "../helpers-crDEr6S2.mjs";
|
|
|
6
6
|
import "../sync--BoxBBok.mjs";
|
|
7
7
|
import "../embeddings-Bn86ssxR.mjs";
|
|
8
8
|
import "../search-CpTv1I24.mjs";
|
|
9
|
-
import { A as registerProjectsCommands, C as registerRestoreCommands, D as registerIdentityCommands, E as registerMcpCommands, M as resolveIdentifier, O as registerMemoryCommands, S as registerSetupCommand, T as registerDaemonCommands, _ as registerUpdateCommand, a as cmdEnd, b as registerZettelCommands, c as cmdGoto, d as registerHelpCommand, f as registerDbCommands, g as registerNotifyCommands, h as registerTaskCommands, i as cmdPauseAll, j as findMovedPath, k as registerRegistryCommands, l as cmdPause, m as registerTopicCommands, n as cmdFind, o as registerSessionCleanupCommand, p as registerKgCommands, r as cmdClearNames, s as registerSessionCommands, t as cmdPick, u as cmdList, v as registerSkillCommands, w as registerBackupCommands, x as registerObsidianCommands, y as registerObservationCommands } from "../pick-
|
|
9
|
+
import { A as registerProjectsCommands, C as registerRestoreCommands, D as registerIdentityCommands, E as registerMcpCommands, M as resolveIdentifier, O as registerMemoryCommands, S as registerSetupCommand, T as registerDaemonCommands, _ as registerUpdateCommand, a as cmdEnd, b as registerZettelCommands, c as cmdGoto, d as registerHelpCommand, f as registerDbCommands, g as registerNotifyCommands, h as registerTaskCommands, i as cmdPauseAll, j as findMovedPath, k as registerRegistryCommands, l as cmdPause, m as registerTopicCommands, n as cmdFind, o as registerSessionCleanupCommand, p as registerKgCommands, r as cmdClearNames, s as registerSessionCommands, t as cmdPick, u as cmdList, v as registerSkillCommands, w as registerBackupCommands, x as registerObsidianCommands, y as registerObservationCommands } from "../pick-DlsM0ppq.mjs";
|
|
10
10
|
import "../checkpoint-block-DYq9RmMU.mjs";
|
|
11
11
|
import "../indexer-AEcT8wHf.mjs";
|
|
12
12
|
import "../ipc-client-aVKVERjJ.mjs";
|
|
@@ -16,7 +16,7 @@ import { s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
|
|
|
16
16
|
import { _ as scanSessions, a as renderDedupedSessions, c as probeResume, d as callAiBroker, f as fetchLiveSessions, g as resolveSessionByNameOrId, h as fmtAge, i as normalizeName$2, l as restoreTopLevel, m as sendToSession, o as hasConversation, p as revealItermSession, r as buildDeduped, s as launchInDir, u as printExitDir } from "./main-resolver-DjyUDJrv.mjs";
|
|
17
17
|
import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { homedir, platform, tmpdir } from "node:os";
|
|
19
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
19
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import chalk from "chalk";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -24,6 +24,61 @@ import { createInterface } from "node:readline";
|
|
|
24
24
|
import { createConnection } from "net";
|
|
25
25
|
import { promisify } from "node:util";
|
|
26
26
|
|
|
27
|
+
//#region src/registry/registrable.ts
|
|
28
|
+
/**
|
|
29
|
+
* registrable.ts — directories that must never become registered projects.
|
|
30
|
+
*
|
|
31
|
+
* A project is a durable thing. The registry, however, has been accepting
|
|
32
|
+
* whatever directory a session happened to start in, and some of those
|
|
33
|
+
* directories are disposable by construction. Measured on the real registry,
|
|
34
|
+
* 2026-08-04:
|
|
35
|
+
*
|
|
36
|
+
* 08 - Others/MDF/Infrastruktur/.claude/worktrees/cool-haibt 1 session
|
|
37
|
+
* 08 - Others/MDF/Infrastruktur/.claude/worktrees/strange-haibt 7 sessions
|
|
38
|
+
* /private/tmp/ops-webui dead
|
|
39
|
+
* /private/tmp/claude-501/-Users-…-AIBroker/aae854c6-… dead
|
|
40
|
+
*
|
|
41
|
+
* The worktrees are agent isolation directories, created to be removed. The temp
|
|
42
|
+
* paths are exactly what their name says. Registering them attributes session
|
|
43
|
+
* history to a location with no future, and lets `pai <name>` route someone into
|
|
44
|
+
* a directory a cleanup can delete underneath them.
|
|
45
|
+
*
|
|
46
|
+
* So this refuses at the point of registration rather than reclassifying
|
|
47
|
+
* afterwards. It is a guard, not a policy about what health should call things —
|
|
48
|
+
* it stops the set growing while the vocabulary question (dead vs duplicate vs
|
|
49
|
+
* misnamed vs ephemeral) is decided separately.
|
|
50
|
+
*
|
|
51
|
+
* Found by the AIBroker session while we were dividing up the dead-path work.
|
|
52
|
+
*/
|
|
53
|
+
/** Path fragments that mark a location as disposable, with why. */
|
|
54
|
+
const EPHEMERAL = [
|
|
55
|
+
{
|
|
56
|
+
fragment: `${sep}.claude${sep}worktrees${sep}`,
|
|
57
|
+
because: "a git worktree created for agent isolation — it is meant to be removed"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
fragment: `${sep}private${sep}tmp${sep}`,
|
|
61
|
+
because: "a system temp directory"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
fragment: `${sep}var${sep}folders${sep}`,
|
|
65
|
+
because: "a macOS per-user temp directory"
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Why this path cannot be a project, or undefined if it can.
|
|
70
|
+
*
|
|
71
|
+
* Returns the reason rather than a boolean so the caller can tell the user which
|
|
72
|
+
* rule caught them — "refused" without a reason invites someone to work around it
|
|
73
|
+
* rather than move their project somewhere durable.
|
|
74
|
+
*/
|
|
75
|
+
function unregistrableReason(rootPath) {
|
|
76
|
+
const padded = rootPath.endsWith(sep) ? rootPath : rootPath + sep;
|
|
77
|
+
for (const { fragment, because } of EPHEMERAL) if (padded.includes(fragment)) return because;
|
|
78
|
+
if (padded.startsWith(`${sep}tmp${sep}`)) return "a system temp directory";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
27
82
|
//#region src/session/promote.ts
|
|
28
83
|
/**
|
|
29
84
|
* Derive a human-readable project name from a session note filename.
|
|
@@ -74,6 +129,13 @@ function cmdPromote(db, opts) {
|
|
|
74
129
|
console.error(err(`Could not derive a valid slug from name: "${displayName}"`));
|
|
75
130
|
process.exit(1);
|
|
76
131
|
}
|
|
132
|
+
const ephemeral = unregistrableReason(targetPath);
|
|
133
|
+
if (ephemeral) {
|
|
134
|
+
console.error(err(`Refusing to promote into ${targetPath}`));
|
|
135
|
+
console.error(dim$1(` That is ${ephemeral}.`));
|
|
136
|
+
console.error(dim$1(` Pick a durable location for the new project.`));
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
77
139
|
const encodedDir = encodeDir(targetPath);
|
|
78
140
|
if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ? OR encoded_dir = ?").get(slug, targetPath, encodedDir)) {
|
|
79
141
|
console.error(err(`A project with slug "${slug}" or path "${targetPath}" is already registered.`));
|
|
@@ -360,6 +422,13 @@ function cmdAdd(db, rawPath, opts) {
|
|
|
360
422
|
console.error(err(`Invalid type "${type}". Valid: ${validTypes.join(", ")}`));
|
|
361
423
|
process.exit(1);
|
|
362
424
|
}
|
|
425
|
+
const ephemeral = unregistrableReason(rootPath);
|
|
426
|
+
if (ephemeral) {
|
|
427
|
+
console.error(err(`Refusing to register ${shortenPath(rootPath, 60)}`));
|
|
428
|
+
console.error(dim$1(` That is ${ephemeral}.`));
|
|
429
|
+
console.error(dim$1(` A project needs a durable location — move it, then register.`));
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
363
432
|
if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ?").get(slug, rootPath)) {
|
|
364
433
|
console.error(err(`Project already registered (slug: ${slug} or path: ${rootPath})`));
|
|
365
434
|
process.exit(1);
|
|
@@ -1170,6 +1239,197 @@ function cmdConfig(db, identifier, opts) {
|
|
|
1170
1239
|
console.log();
|
|
1171
1240
|
}
|
|
1172
1241
|
|
|
1242
|
+
//#endregion
|
|
1243
|
+
//#region src/cli/commands/project/relocate.ts
|
|
1244
|
+
/**
|
|
1245
|
+
* relocate.ts — find a registered project whose ancestor directory was renamed.
|
|
1246
|
+
*
|
|
1247
|
+
* The registry stores absolute root paths. Rename any directory ABOVE a project
|
|
1248
|
+
* and every project underneath it goes missing at once — `existsSync` fails, and
|
|
1249
|
+
* the health check reported them as dead and offered to archive them. Renaming
|
|
1250
|
+
* `Ideaverse` to `🧠 Ideaverse` orphaned a whole subtree that way: 32 entries, all
|
|
1251
|
+
* still on disk, none of them findable.
|
|
1252
|
+
*
|
|
1253
|
+
* The old suggestion logic could not see this. It took the project's BASENAME and
|
|
1254
|
+
* looked for it in four hardcoded directories (`~/dev`, `~/dev/ai`, `~/Desktop`,
|
|
1255
|
+
* `~/Projects`), so it could only recognise "the leaf moved into a place I already
|
|
1256
|
+
* know about". A renamed ancestor leaves the leaf exactly where it was.
|
|
1257
|
+
*
|
|
1258
|
+
* So walk down from the root while the path still exists — that lands on the
|
|
1259
|
+
* deepest surviving ancestor, which is precisely where the rename happened — then
|
|
1260
|
+
* match the remaining segments by NORMALISED name. Normalising strips the emoji,
|
|
1261
|
+
* the space and the case, so `🧠 Ideaverse` and `Ideaverse` both reduce to
|
|
1262
|
+
* "ideaverse". The general case is "an ancestor was renamed decoratively", which
|
|
1263
|
+
* is the whole class of failure rather than this one instance of it.
|
|
1264
|
+
*
|
|
1265
|
+
* Algorithm and the measurements behind it come from the AIBroker session, which
|
|
1266
|
+
* probed it and handed it over rather than implementing it in a file that was not
|
|
1267
|
+
* theirs.
|
|
1268
|
+
*/
|
|
1269
|
+
/**
|
|
1270
|
+
* A directory name reduced to what a rename is unlikely to have changed.
|
|
1271
|
+
*
|
|
1272
|
+
* NFC first because macOS hands back decomposed Unicode: an "ä" typed in one
|
|
1273
|
+
* place and read from a directory listing in another are different byte
|
|
1274
|
+
* sequences, and comparing them raw fails for reasons no one can see. Then case
|
|
1275
|
+
* and every non-alphanumeric are dropped, which is what makes `🧠 Ideaverse`
|
|
1276
|
+
* match `Ideaverse` — and, unavoidably, what makes `my-project` match
|
|
1277
|
+
* `my project`. That looseness is the point, and the uniqueness rule below is
|
|
1278
|
+
* what keeps it safe.
|
|
1279
|
+
*/
|
|
1280
|
+
function norm(s) {
|
|
1281
|
+
return s.normalize("NFC").toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "");
|
|
1282
|
+
}
|
|
1283
|
+
/** Hidden by Unix convention — tool state rather than a project directory. */
|
|
1284
|
+
function isHidden(name) {
|
|
1285
|
+
return name.startsWith(".");
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Drop a leading ordering prefix: `20 - Webseiten` -> `Webseiten`.
|
|
1289
|
+
*
|
|
1290
|
+
* This vault numbers directories to force sort order — `04 - Ablage`,
|
|
1291
|
+
* `08 - Others`, `01 - Base Setup`, `20 - Webseiten` — and adding or removing that
|
|
1292
|
+
* prefix is a rename of exactly the same kind as adding an emoji. `norm()` alone
|
|
1293
|
+
* cannot see it, because the digits survive normalisation: "webseiten" against
|
|
1294
|
+
* "20webseiten".
|
|
1295
|
+
*
|
|
1296
|
+
* Found because `MDF.md` links to `Infrastruktur/20 - Webseiten` while the
|
|
1297
|
+
* registry has a dead entry for plain `Infrastruktur/Webseiten`. The note files
|
|
1298
|
+
* knew where it went.
|
|
1299
|
+
*
|
|
1300
|
+
* Deliberately NOT a general suffix match, and the reason is worth spelling out
|
|
1301
|
+
* because "why not just match on the suffix" is the obvious next question.
|
|
1302
|
+
*
|
|
1303
|
+
* `MDF/Infrastruktur/` is wall-to-wall numbered: `00 - Migration Analysis`,
|
|
1304
|
+
* `01 - Base Setup`, `02 - Storage Setup`, `03 - Network Setup`, and so on. A
|
|
1305
|
+
* suffix rule on a wanted `Setup` hits eight of them at once — so the uniqueness
|
|
1306
|
+
* rule below would veto it, and the danger would never show itself. The one that
|
|
1307
|
+
* bites is a wanted `Analysis` against `00 - Migration Analysis`: a single hit,
|
|
1308
|
+
* therefore silently relocated, therefore wrong, and uniqueness cannot save you
|
|
1309
|
+
* because there is nothing ambiguous about it.
|
|
1310
|
+
*
|
|
1311
|
+
* That is a failure mode this file's own safety rule would have HIDDEN rather than
|
|
1312
|
+
* caught. Only a leading run of digits with optional separator comes off.
|
|
1313
|
+
*/
|
|
1314
|
+
function stripOrderingPrefix(name) {
|
|
1315
|
+
return name.replace(/^\d+\s*[-._)]?\s*/, "");
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The names under which a directory can be recognised.
|
|
1319
|
+
*
|
|
1320
|
+
* Both spellings are offered from both sides, so the prefix can have been added
|
|
1321
|
+
* OR removed since the path was registered.
|
|
1322
|
+
*/
|
|
1323
|
+
function matchKeys(name) {
|
|
1324
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1325
|
+
const bare = norm(name);
|
|
1326
|
+
if (bare) keys.add(bare);
|
|
1327
|
+
const stripped = norm(stripOrderingPrefix(name));
|
|
1328
|
+
if (stripped) keys.add(stripped);
|
|
1329
|
+
return keys;
|
|
1330
|
+
}
|
|
1331
|
+
function shareAKey(a, b) {
|
|
1332
|
+
for (const k of a) if (b.has(k)) return true;
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Resolve symlinks so two spellings of one directory compare equal.
|
|
1337
|
+
*
|
|
1338
|
+
* Falls back to the input when it cannot resolve — a path that does not exist has
|
|
1339
|
+
* no real path, and for the duplicate check below "unresolvable" must not
|
|
1340
|
+
* accidentally equal some other unresolvable path.
|
|
1341
|
+
*/
|
|
1342
|
+
function realOrSelf(path) {
|
|
1343
|
+
try {
|
|
1344
|
+
return realpathSync(path);
|
|
1345
|
+
} catch {
|
|
1346
|
+
return path;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function isDir(path) {
|
|
1350
|
+
try {
|
|
1351
|
+
return statSync(path).isDirectory();
|
|
1352
|
+
} catch {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Where a project went when a directory above it was renamed.
|
|
1358
|
+
*
|
|
1359
|
+
* Returns undefined rather than a guess whenever it cannot be certain — see the
|
|
1360
|
+
* uniqueness rule inside. undefined means "still dead as far as this can tell",
|
|
1361
|
+
* which leaves the entry exactly as it was.
|
|
1362
|
+
*/
|
|
1363
|
+
function relocateRenamedAncestor(rootPath) {
|
|
1364
|
+
if (existsSync(rootPath)) return void 0;
|
|
1365
|
+
const segments = rootPath.split(sep).filter((s) => s.length > 0);
|
|
1366
|
+
if (segments.length === 0) return void 0;
|
|
1367
|
+
let current = rootPath.startsWith(sep) ? sep : "";
|
|
1368
|
+
let i = 0;
|
|
1369
|
+
for (; i < segments.length; i++) {
|
|
1370
|
+
const next = join(current, segments[i]);
|
|
1371
|
+
if (!existsSync(next)) break;
|
|
1372
|
+
current = next;
|
|
1373
|
+
}
|
|
1374
|
+
if (i === segments.length) return void 0;
|
|
1375
|
+
for (; i < segments.length; i++) {
|
|
1376
|
+
const wanted = matchKeys(segments[i]);
|
|
1377
|
+
if (wanted.size === 0) return void 0;
|
|
1378
|
+
let children;
|
|
1379
|
+
try {
|
|
1380
|
+
children = readdirSync(current);
|
|
1381
|
+
} catch {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const hits = children.filter((c) => isDir(join(current, c)) && shareAKey(matchKeys(c), wanted) && isHidden(c) === isHidden(segments[i]));
|
|
1385
|
+
if (hits.length !== 1) return void 0;
|
|
1386
|
+
current = join(current, hits[0]);
|
|
1387
|
+
}
|
|
1388
|
+
return current;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* The pre-existing guess: the project's leaf name in one of a few usual places.
|
|
1392
|
+
*
|
|
1393
|
+
* Kept, and kept SECOND, because it answers a different question — "the project
|
|
1394
|
+
* itself was moved somewhere I know about" rather than "an ancestor was renamed".
|
|
1395
|
+
* It is the weaker of the two: matching on basename alone can point at an
|
|
1396
|
+
* unrelated directory that happens to share a name, so it only gets to answer
|
|
1397
|
+
* when the ancestor walk has declined.
|
|
1398
|
+
*/
|
|
1399
|
+
function suggestByBasename(rootPath) {
|
|
1400
|
+
const name = basename(rootPath);
|
|
1401
|
+
const candidates = [
|
|
1402
|
+
join(homedir(), "dev", name),
|
|
1403
|
+
join(homedir(), "dev", "ai", name),
|
|
1404
|
+
join(homedir(), "Desktop", name),
|
|
1405
|
+
join(homedir(), "Projects", name)
|
|
1406
|
+
];
|
|
1407
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Where a missing project probably is, or undefined.
|
|
1411
|
+
*
|
|
1412
|
+
* Feeds the health check's `suggestedPath ? "stale" : "dead"` classification, so
|
|
1413
|
+
* an answer here moves an entry out of the dead list and into the one `--fix`
|
|
1414
|
+
* repairs.
|
|
1415
|
+
*
|
|
1416
|
+
* `otherRoots` is every OTHER registered project's root path, and it is what
|
|
1417
|
+
* stops a repair from becoming a duplicate. Caught by the AIBroker session on the
|
|
1418
|
+
* first version of this: `~/PAI` was "recovered" to `~/dev/ai/PAI`, which is a
|
|
1419
|
+
* second spelling of `~/Daten/Cloud/Development/ai/PAI` — a path an ACTIVE project
|
|
1420
|
+
* already owns. Two registry entries for one directory is the exact mess that was
|
|
1421
|
+
* merged out of this registry earlier the same day.
|
|
1422
|
+
*
|
|
1423
|
+
* The comparison must be by realpath, not string. String equality is precisely
|
|
1424
|
+
* what missed it: the two paths share not one character after `/Users/i052341/`.
|
|
1425
|
+
*/
|
|
1426
|
+
function suggestMovedPath(rootPath, otherRoots = []) {
|
|
1427
|
+
const candidate = relocateRenamedAncestor(rootPath) ?? suggestByBasename(rootPath);
|
|
1428
|
+
if (!candidate) return void 0;
|
|
1429
|
+
if (new Set(otherRoots.map(realOrSelf)).has(realOrSelf(candidate))) return void 0;
|
|
1430
|
+
return candidate;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1173
1433
|
//#endregion
|
|
1174
1434
|
//#region src/cli/commands/project/health.ts
|
|
1175
1435
|
function findOrphanedNotesDirs(project) {
|
|
@@ -1193,15 +1453,66 @@ function findOrphanedNotesDirs(project) {
|
|
|
1193
1453
|
} catch {}
|
|
1194
1454
|
return results;
|
|
1195
1455
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1456
|
+
/**
|
|
1457
|
+
* Why a row is unhealthy, and what to do about it.
|
|
1458
|
+
*
|
|
1459
|
+
* `category` said only dead / stale / active, and "archive" was offered as the
|
|
1460
|
+
* remedy for everything. Four different situations were collapsed into that, and
|
|
1461
|
+
* archiving is right for exactly one of them:
|
|
1462
|
+
*
|
|
1463
|
+
* EPHEMERAL the path is a worktree or a temp dir — it should never have been
|
|
1464
|
+
* registered. Checked FIRST, deliberately: a temp path whose
|
|
1465
|
+
* directory has also vanished is both ephemeral and dead, and
|
|
1466
|
+
* "should never have been registered" is the stronger and more
|
|
1467
|
+
* actionable statement. Without a stated precedence the same row
|
|
1468
|
+
* gets different labels depending on evaluation order.
|
|
1469
|
+
* DUPLICATE the path is gone and another project owns where it went. Its
|
|
1470
|
+
* sessions are the only thing of value on it, so the remedy is
|
|
1471
|
+
* merge — archiving strands them.
|
|
1472
|
+
* MISNAMED the path EXISTS and another project owns a subtree of it, under a
|
|
1473
|
+
* slug that has nothing to do with it. `pferde` on `08 - Others/MDF`.
|
|
1474
|
+
* health never reported this at all, because existsSync says yes.
|
|
1475
|
+
* DEAD the path is gone and nothing claims it. Archive is correct here.
|
|
1476
|
+
*
|
|
1477
|
+
* The action must never name a command that destroys a row holding sessions —
|
|
1478
|
+
* a wrong command in an action field reads as vetted.
|
|
1479
|
+
*/
|
|
1480
|
+
function diagnose(project, pathExists, others) {
|
|
1481
|
+
const ephemeral = unregistrableReason(project.root_path);
|
|
1482
|
+
if (ephemeral) return {
|
|
1483
|
+
reason: "ephemeral",
|
|
1484
|
+
action: project.session_count > 0 ? `holds ${project.session_count} session(s) — pai project merge ${project.slug} <durable-project> --execute, then unregister` : `pai project unregister ${project.slug} --execute (${ephemeral})`
|
|
1485
|
+
};
|
|
1486
|
+
if (pathExists) {
|
|
1487
|
+
const nested = others.find((o) => o.status === "active" && o.root_path.startsWith(project.root_path + "/") && existsSync(o.root_path));
|
|
1488
|
+
if (nested && project.status !== "active") return {
|
|
1489
|
+
reason: "misnamed",
|
|
1490
|
+
owner: nested.slug,
|
|
1491
|
+
action: `live directory, but ${nested.slug} owns a subtree of it — rename, or pai project merge ${project.slug} ${nested.slug}`
|
|
1492
|
+
};
|
|
1493
|
+
return {};
|
|
1494
|
+
}
|
|
1495
|
+
const suggestion = suggestMovedPath(project.root_path, []);
|
|
1496
|
+
if (suggestion) {
|
|
1497
|
+
const owner = others.find((o) => o.root_path === suggestion || realpathEq(o.root_path, suggestion));
|
|
1498
|
+
if (owner) return {
|
|
1499
|
+
reason: "duplicate",
|
|
1500
|
+
owner: owner.slug,
|
|
1501
|
+
action: project.session_count > 0 ? `pai project merge ${project.slug} ${owner.slug} (moves ${project.session_count} session(s))` : `pai project merge ${project.slug} ${owner.slug} (no sessions — a plain drop)`
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
reason: "dead",
|
|
1506
|
+
action: project.session_count > 0 ? `holds ${project.session_count} session(s) — merge before archiving, or they become unreachable` : `pai project archive ${project.slug}`
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
/** Same directory under two spellings — symlinks, not string equality. */
|
|
1510
|
+
function realpathEq(a, b) {
|
|
1511
|
+
try {
|
|
1512
|
+
return realpathSync(a) === realpathSync(b);
|
|
1513
|
+
} catch {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1205
1516
|
}
|
|
1206
1517
|
function cmdHealth$1(db, opts) {
|
|
1207
1518
|
const rows = db.prepare(`SELECT p.*,
|
|
@@ -1211,19 +1522,24 @@ function cmdHealth$1(db, opts) {
|
|
|
1211
1522
|
const results = rows.map((project) => {
|
|
1212
1523
|
const pathExists = existsSync(project.root_path);
|
|
1213
1524
|
const orphaned = findOrphanedNotesDirs(project);
|
|
1525
|
+
const others = rows.filter((r) => r.id !== project.id);
|
|
1214
1526
|
let category;
|
|
1215
1527
|
let suggestedPath;
|
|
1216
1528
|
if (pathExists) category = "active";
|
|
1217
1529
|
else {
|
|
1218
|
-
suggestedPath = suggestMovedPath(project);
|
|
1530
|
+
suggestedPath = suggestMovedPath(project.root_path, others.map((r) => r.root_path));
|
|
1219
1531
|
category = suggestedPath ? "stale" : "dead";
|
|
1220
1532
|
}
|
|
1533
|
+
const { reason, owner, action } = diagnose(project, pathExists, others);
|
|
1221
1534
|
return {
|
|
1222
1535
|
project,
|
|
1223
1536
|
category,
|
|
1224
1537
|
suggestedPath,
|
|
1225
1538
|
claudeNotesExists: orphaned.length > 0,
|
|
1226
|
-
orphanedNotesDirs: orphaned
|
|
1539
|
+
orphanedNotesDirs: orphaned,
|
|
1540
|
+
reason,
|
|
1541
|
+
owner,
|
|
1542
|
+
action
|
|
1227
1543
|
};
|
|
1228
1544
|
});
|
|
1229
1545
|
const filtered = opts.status ? results.filter((r) => r.category === opts.status) : results;
|
|
@@ -1236,7 +1552,10 @@ function cmdHealth$1(db, opts) {
|
|
|
1236
1552
|
session_count: r.project.session_count,
|
|
1237
1553
|
suggested_path: r.suggestedPath ?? null,
|
|
1238
1554
|
claude_notes_exists: r.claudeNotesExists,
|
|
1239
|
-
orphaned_notes_dirs: r.orphanedNotesDirs
|
|
1555
|
+
orphaned_notes_dirs: r.orphanedNotesDirs,
|
|
1556
|
+
reason: r.reason ?? null,
|
|
1557
|
+
owner: r.owner ?? null,
|
|
1558
|
+
action: r.action ?? null
|
|
1240
1559
|
})), null, 2));
|
|
1241
1560
|
return;
|
|
1242
1561
|
}
|
|
@@ -1281,24 +1600,179 @@ function cmdHealth$1(db, opts) {
|
|
|
1281
1600
|
console.log();
|
|
1282
1601
|
}
|
|
1283
1602
|
if (dead.length) {
|
|
1284
|
-
console.log(err("
|
|
1603
|
+
console.log(err(" Unreachable projects (path missing):"));
|
|
1285
1604
|
for (const r of dead) {
|
|
1286
|
-
|
|
1605
|
+
const label = r.reason && r.reason !== "dead" ? warn$1(` [${r.reason}]`) : "";
|
|
1606
|
+
console.log(` ${bold$1(r.project.slug)} ${dim$1(r.project.root_path)}${label}`);
|
|
1287
1607
|
if (r.claudeNotesExists) console.log(chalk.yellow(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
|
|
1288
|
-
if (r.project.session_count === 0 && opts.fix) {
|
|
1608
|
+
if (r.project.session_count === 0 && opts.fix && r.reason === "dead") {
|
|
1289
1609
|
db.prepare("UPDATE projects SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), r.project.id);
|
|
1290
|
-
console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone)"));
|
|
1291
|
-
} else console.log(dim$1(`
|
|
1610
|
+
console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone, nothing claims it)"));
|
|
1611
|
+
} else if (r.action) console.log(dim$1(` Do: ${r.action}`));
|
|
1292
1612
|
}
|
|
1293
1613
|
console.log();
|
|
1294
1614
|
}
|
|
1295
|
-
|
|
1615
|
+
const archivedButPresent = results.filter((r) => r.category === "active" && r.project.status !== "active").length;
|
|
1616
|
+
console.log(dim$1(` ${rows.length} total: ${active.length} with the path present, ${stale.length} stale, ${dead.length} dead`));
|
|
1617
|
+
if (archivedButPresent > 0) console.log(dim$1(` ${archivedButPresent} of those ${active.length} are archived in the registry — "present" here is about the directory, not the registry status.`));
|
|
1296
1618
|
if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
|
|
1297
1619
|
console.log();
|
|
1298
1620
|
console.log(warn$1(" Run with --fix to auto-remediate where possible."));
|
|
1299
1621
|
}
|
|
1300
1622
|
}
|
|
1301
1623
|
|
|
1624
|
+
//#endregion
|
|
1625
|
+
//#region src/registry/merge.ts
|
|
1626
|
+
var MergeError = class extends Error {};
|
|
1627
|
+
/**
|
|
1628
|
+
* What a merge would do, without doing it.
|
|
1629
|
+
*
|
|
1630
|
+
* Built as a plan first because the session renumbering is the part a reader will
|
|
1631
|
+
* not predict, and printing "0001 -> 0016" is the difference between a command
|
|
1632
|
+
* that can be trusted and one that has to be taken on faith.
|
|
1633
|
+
*/
|
|
1634
|
+
function planMerge(db, fromSlug, intoSlug) {
|
|
1635
|
+
if (fromSlug === intoSlug) throw new MergeError(`Cannot merge ${fromSlug} into itself.`);
|
|
1636
|
+
const from = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(fromSlug);
|
|
1637
|
+
const into = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(intoSlug);
|
|
1638
|
+
if (!from) throw new MergeError(`No project with slug "${fromSlug}".`);
|
|
1639
|
+
if (!into) throw new MergeError(`No project with slug "${intoSlug}".`);
|
|
1640
|
+
let next = db.prepare("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = ?").get(into.id).n;
|
|
1641
|
+
const sessions = db.prepare("SELECT id, number FROM sessions WHERE project_id = ? ORDER BY number ASC").all(from.id).map((s) => ({
|
|
1642
|
+
id: s.id,
|
|
1643
|
+
from: s.number,
|
|
1644
|
+
to: ++next
|
|
1645
|
+
}));
|
|
1646
|
+
const count = (sql, ...args) => db.prepare(sql).get(...args).n;
|
|
1647
|
+
const aliasTaken = count("SELECT COUNT(*) AS n FROM aliases WHERE alias = ?", from.slug) > 0 || count("SELECT COUNT(*) AS n FROM projects WHERE slug = ?", from.slug) > 1;
|
|
1648
|
+
return {
|
|
1649
|
+
fromId: from.id,
|
|
1650
|
+
fromSlug: from.slug,
|
|
1651
|
+
intoId: into.id,
|
|
1652
|
+
intoSlug: into.slug,
|
|
1653
|
+
sessions,
|
|
1654
|
+
tags: count("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?", from.id),
|
|
1655
|
+
aliases: count("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?", from.id),
|
|
1656
|
+
compactions: count("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?", from.id),
|
|
1657
|
+
links: count("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?", from.id),
|
|
1658
|
+
aliasToAdd: aliasTaken ? void 0 : from.slug
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Apply a plan. One transaction: either the whole row is folded in or nothing is.
|
|
1663
|
+
*
|
|
1664
|
+
* A half-merged project is the worst outcome available here — sessions moved but
|
|
1665
|
+
* the row still present, or the row gone and its tags orphaned — and with foreign
|
|
1666
|
+
* keys off nothing would complain.
|
|
1667
|
+
*/
|
|
1668
|
+
function applyMerge(db, plan) {
|
|
1669
|
+
db.transaction(() => {
|
|
1670
|
+
const move = db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?");
|
|
1671
|
+
for (const s of plan.sessions) move.run(plan.intoId, s.to, s.id);
|
|
1672
|
+
db.prepare(`INSERT OR IGNORE INTO project_tags (project_id, tag_id)
|
|
1673
|
+
SELECT ?, tag_id FROM project_tags WHERE project_id = ?`).run(plan.intoId, plan.fromId);
|
|
1674
|
+
db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(plan.fromId);
|
|
1675
|
+
db.prepare("UPDATE aliases SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
|
|
1676
|
+
db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
|
|
1677
|
+
db.prepare(`UPDATE OR IGNORE links SET target_project_id = ? WHERE target_project_id = ?`).run(plan.intoId, plan.fromId);
|
|
1678
|
+
db.prepare("DELETE FROM links WHERE target_project_id = ?").run(plan.fromId);
|
|
1679
|
+
db.prepare(`DELETE FROM links WHERE target_project_id = ?
|
|
1680
|
+
AND session_id IN (SELECT id FROM sessions WHERE project_id = ?)`).run(plan.intoId, plan.intoId);
|
|
1681
|
+
if (plan.aliasToAdd) db.prepare("INSERT OR IGNORE INTO aliases (alias, project_id) VALUES (?, ?)").run(plan.aliasToAdd, plan.intoId);
|
|
1682
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(plan.fromId);
|
|
1683
|
+
})();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/cli/commands/project/merge.ts
|
|
1688
|
+
function cmdMerge(db, fromSlug, intoSlug, opts = {}) {
|
|
1689
|
+
let plan;
|
|
1690
|
+
try {
|
|
1691
|
+
plan = planMerge(db, fromSlug, intoSlug);
|
|
1692
|
+
} catch (e) {
|
|
1693
|
+
if (e instanceof MergeError) {
|
|
1694
|
+
console.error(err(` ${e.message}`));
|
|
1695
|
+
process.exit(1);
|
|
1696
|
+
}
|
|
1697
|
+
throw e;
|
|
1698
|
+
}
|
|
1699
|
+
const path = (id) => {
|
|
1700
|
+
const row = db.prepare("SELECT root_path FROM projects WHERE id = ?").get(id);
|
|
1701
|
+
return row ? shortenPath(row.root_path, 54) : "(unknown)";
|
|
1702
|
+
};
|
|
1703
|
+
console.log();
|
|
1704
|
+
console.log(bold$1(` Merge ${plan.fromSlug} into ${plan.intoSlug}`));
|
|
1705
|
+
console.log();
|
|
1706
|
+
console.log(dim$1(` from ${plan.fromSlug.padEnd(22)} ${path(plan.fromId)}`));
|
|
1707
|
+
console.log(dim$1(` into ${plan.intoSlug.padEnd(22)} ${path(plan.intoId)}`));
|
|
1708
|
+
console.log();
|
|
1709
|
+
if (plan.sessions.length > 0) {
|
|
1710
|
+
console.log(` ${plan.sessions.length} session(s) move and are renumbered:`);
|
|
1711
|
+
for (const s of plan.sessions) console.log(dim$1(` ${String(s.from).padStart(4)} -> ${String(s.to).padStart(4)}`));
|
|
1712
|
+
} else console.log(dim$1(` No sessions to move.`));
|
|
1713
|
+
const extras = [];
|
|
1714
|
+
if (plan.tags) extras.push(`${plan.tags} tag(s)`);
|
|
1715
|
+
if (plan.aliases) extras.push(`${plan.aliases} existing alias(es)`);
|
|
1716
|
+
if (plan.compactions) extras.push(`${plan.compactions} compaction record(s)`);
|
|
1717
|
+
if (plan.links) extras.push(`${plan.links} inbound link(s)`);
|
|
1718
|
+
if (extras.length > 0) console.log(dim$1(` Also repointed: ${extras.join(", ")}.`));
|
|
1719
|
+
if (plan.aliasToAdd) console.log(dim$1(` "${plan.aliasToAdd}" is kept as an alias, so the old name still resolves.`));
|
|
1720
|
+
else console.log(warn$1(` "${plan.fromSlug}" cannot be kept as an alias — the name is already taken.`));
|
|
1721
|
+
console.log(dim$1(` Then the ${plan.fromSlug} row is deleted.`));
|
|
1722
|
+
console.log();
|
|
1723
|
+
if (!opts.execute) {
|
|
1724
|
+
console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to merge."));
|
|
1725
|
+
console.log();
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
applyMerge(db, plan);
|
|
1729
|
+
console.log(ok$1(` Merged. ${plan.sessions.length} session(s) now belong to ${plan.intoSlug}.`));
|
|
1730
|
+
console.log();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
//#endregion
|
|
1734
|
+
//#region src/cli/commands/project/unregister.ts
|
|
1735
|
+
function cmdUnregister(db, slug, opts = {}) {
|
|
1736
|
+
const row = db.prepare(`SELECT p.id, p.slug, p.root_path, p.status,
|
|
1737
|
+
(SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
|
|
1738
|
+
FROM projects p WHERE p.slug = ?`).get(slug);
|
|
1739
|
+
if (!row) {
|
|
1740
|
+
console.error(err(` No project with slug "${slug}".`));
|
|
1741
|
+
process.exit(1);
|
|
1742
|
+
}
|
|
1743
|
+
console.log();
|
|
1744
|
+
console.log(bold$1(` Unregister ${row.slug}`));
|
|
1745
|
+
console.log(dim$1(` ${row.root_path}`));
|
|
1746
|
+
console.log(dim$1(` status ${row.status}, ${row.session_count} session(s)`));
|
|
1747
|
+
console.log();
|
|
1748
|
+
if (row.session_count > 0 && !opts.force) {
|
|
1749
|
+
console.log(warn$1(` Refusing: ${row.session_count} session(s) would be stranded.`));
|
|
1750
|
+
console.log(dim$1(` Those sessions are the only thing of value on this row. Move them first:`));
|
|
1751
|
+
console.log(dim$1(` pai project merge ${row.slug} <into> --execute`));
|
|
1752
|
+
console.log(dim$1(` Or pass --force if the sessions are genuinely worthless.`));
|
|
1753
|
+
console.log();
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
}
|
|
1756
|
+
if (!opts.execute) {
|
|
1757
|
+
console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to unregister."));
|
|
1758
|
+
if (row.session_count > 0) console.log(warn$1(` --force is set: ${row.session_count} session(s) WILL be deleted.`));
|
|
1759
|
+
console.log();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
db.transaction(() => {
|
|
1763
|
+
db.prepare("DELETE FROM links WHERE target_project_id = ?").run(row.id);
|
|
1764
|
+
db.prepare("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = ?)").run(row.id);
|
|
1765
|
+
db.prepare("DELETE FROM compaction_log WHERE project_id = ?").run(row.id);
|
|
1766
|
+
db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(row.id);
|
|
1767
|
+
db.prepare("DELETE FROM aliases WHERE project_id = ?").run(row.id);
|
|
1768
|
+
db.prepare("DELETE FROM sessions WHERE project_id = ?").run(row.id);
|
|
1769
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(row.id);
|
|
1770
|
+
})();
|
|
1771
|
+
console.log(ok$1(` Unregistered ${row.slug}.`));
|
|
1772
|
+
console.log(dim$1(` The directory itself was not touched.`));
|
|
1773
|
+
console.log();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1302
1776
|
//#endregion
|
|
1303
1777
|
//#region src/cli/commands/project/projects-index.ts
|
|
1304
1778
|
function registerProjectsCommands(projectsCmd, getDb) {
|
|
@@ -1351,6 +1825,12 @@ function registerProjectsCommands(projectsCmd, getDb) {
|
|
|
1351
1825
|
projectsCmd.command("archive <slug>").description("Archive a project").action((slug) => {
|
|
1352
1826
|
cmdArchive(getDb(), slug);
|
|
1353
1827
|
});
|
|
1828
|
+
projectsCmd.command("merge <from> <into>").description("Fold a duplicate project into another: move its sessions (renumbered), repoint its tags, aliases, compaction records and links, keep the old slug as an alias, then delete the row. Preview unless --execute is given.").option("--execute", "Actually perform the merge").action((from, into, opts) => {
|
|
1829
|
+
cmdMerge(getDb(), from, into, opts);
|
|
1830
|
+
});
|
|
1831
|
+
projectsCmd.command("unregister <slug>").description("Remove a project row entirely, for paths that should never have been registered (worktrees, temp dirs). Refuses when the row holds sessions — merge those first. Preview unless --execute is given. The directory itself is never touched.").option("--execute", "Actually remove the row").option("--force", "Remove even though sessions would be deleted with it").action((slug, opts) => {
|
|
1832
|
+
cmdUnregister(getDb(), slug, opts);
|
|
1833
|
+
});
|
|
1354
1834
|
projectsCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
|
|
1355
1835
|
cmdUnarchive(getDb(), slug);
|
|
1356
1836
|
});
|
|
@@ -13581,4 +14061,4 @@ async function cmdPick(db, opts = {}) {
|
|
|
13581
14061
|
|
|
13582
14062
|
//#endregion
|
|
13583
14063
|
export { registerProjectsCommands as A, registerRestoreCommands as C, registerIdentityCommands as D, registerMcpCommands as E, resolveIdentifier as M, registerMemoryCommands as O, registerSetupCommand as S, registerDaemonCommands as T, registerUpdateCommand as _, cmdEnd as a, registerZettelCommands as b, cmdGoto as c, registerHelpCommand as d, registerDbCommands as f, registerNotifyCommands as g, registerTaskCommands as h, cmdPauseAll as i, findMovedPath as j, registerRegistryCommands as k, cmdPause as l, registerTopicCommands as m, cmdFind as n, registerSessionCleanupCommand as o, registerKgCommands as p, cmdClearNames as r, registerSessionCommands as s, cmdPick as t, cmdList as u, registerSkillCommands as v, registerBackupCommands as w, registerObsidianCommands as x, registerObservationCommands as y };
|
|
13584
|
-
//# sourceMappingURL=pick-
|
|
14064
|
+
//# sourceMappingURL=pick-DlsM0ppq.mjs.map
|