@drewpayment/mink 0.10.1 → 0.11.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.js +1406 -868
  39. package/package.json +1 -1
  40. package/src/commands/init.ts +29 -4
  41. package/src/commands/note.ts +2 -2
  42. package/src/commands/session-start.ts +8 -2
  43. package/src/commands/sync-migrate.ts +404 -7
  44. package/src/commands/sync.ts +5 -2
  45. package/src/core/dashboard-server.ts +13 -5
  46. package/src/core/git-identity.ts +120 -0
  47. package/src/core/paths.ts +19 -3
  48. package/src/core/project-id.ts +142 -5
  49. package/src/core/project-registry.ts +122 -13
  50. package/src/core/sync.ts +7 -1
  51. package/src/types/config.ts +9 -0
  52. /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WDjkNLHEd_wI-oOzLyblH}/_buildManifest.js +0 -0
  53. /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WDjkNLHEd_wI-oOzLyblH}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.10.1",
3
+ "version": "0.11.0-beta.1",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,8 +2,10 @@ import { execSync } from "child_process";
2
2
  import { mkdirSync, existsSync } from "fs";
3
3
  import { resolve, dirname, basename, join } from "path";
4
4
  import { projectDir, projectMetaPath } from "../core/paths";
5
- import { generateProjectId } from "../core/project-id";
5
+ import { resolveProjectIdentity } from "../core/project-id";
6
6
  import { atomicWriteJson, atomicWriteText, safeReadJson } from "../core/fs-utils";
7
+ import { getOrCreateDeviceId } from "../core/device";
8
+ import { getRepoRoot, getRepoRemote } from "../core/git-identity";
7
9
  import {
8
10
  isWikiEnabled,
9
11
  isVaultInitialized,
@@ -176,21 +178,32 @@ export async function init(cwd: string): Promise<void> {
176
178
 
177
179
  mkdirSync(dir, { recursive: true });
178
180
 
179
- const projectId = generateProjectId(cwd);
181
+ const identity = resolveProjectIdentity(cwd);
182
+ const projectId = identity.id;
180
183
 
181
184
  // Detect notes project type
182
185
  const isNotesProject =
183
186
  isWikiEnabled() && isVaultInitialized() && isInsideVault(cwd);
184
187
 
185
- // Write project metadata
188
+ // Write project metadata. Lift cwd into the per-device map alongside the
189
+ // legacy singular field so older mink versions (which only read `cwd`) keep
190
+ // working after a downgrade and new versions can track each device's path.
186
191
  const metaPath = projectMetaPath(cwd);
187
192
  const existingMeta = safeReadJson(metaPath) as Record<string, unknown> | null;
193
+ const deviceId = getOrCreateDeviceId();
194
+ const existingPathsByDevice =
195
+ existingMeta?.pathsByDevice &&
196
+ typeof existingMeta.pathsByDevice === "object" &&
197
+ !Array.isArray(existingMeta.pathsByDevice)
198
+ ? (existingMeta.pathsByDevice as Record<string, string>)
199
+ : {};
188
200
  atomicWriteJson(metaPath, {
189
201
  ...(existingMeta ?? {}),
190
202
  cwd,
191
203
  name: basename(cwd),
192
204
  initTimestamp: existingMeta?.initTimestamp ?? new Date().toISOString(),
193
205
  version: "0.1.0",
206
+ pathsByDevice: { ...existingPathsByDevice, [deviceId]: cwd },
194
207
  ...(isNotesProject ? { projectType: "notes" } : {}),
195
208
  });
196
209
 
@@ -201,13 +214,25 @@ export async function init(cwd: string): Promise<void> {
201
214
  console.log(` rule: ${rulePath}`);
202
215
  } else {
203
216
  console.log(`[mink] initialized`);
204
- console.log(` project: ${projectId}`);
217
+ console.log(` project: ${projectId} (${identity.source})`);
205
218
  console.log(` state: ${dir}`);
206
219
  console.log(` runtime: ${runtime}`);
207
220
  console.log(` hooks: ${settingsPath}`);
208
221
  console.log(` rule: ${rulePath}`);
209
222
  }
210
223
 
224
+ // Surface a one-time hint when the project is in a git repo with no remote
225
+ // configured — that's the only case where stable cross-machine identity is
226
+ // a config-fix away.
227
+ if (identity.source === "path-derived") {
228
+ const root = getRepoRoot(cwd);
229
+ if (root && !getRepoRemote(cwd)) {
230
+ console.log(
231
+ ` note: this repo has no remote configured. Project state will not unify across machines until you add one and run \`mink config projects.identity git-remote\`.`
232
+ );
233
+ }
234
+ }
235
+
211
236
  // Run initial scan
212
237
  const { scan } = await import("./scan");
213
238
  scan(cwd, { check: false });
@@ -6,7 +6,7 @@ import {
6
6
  resolveVaultPath,
7
7
  } from "../core/vault";
8
8
  import { resolveConfigValue } from "../core/global-config";
9
- import { generateProjectId } from "../core/project-id";
9
+ import { projectIdFor } from "../core/project-id";
10
10
  import {
11
11
  createNote,
12
12
  appendToDaily,
@@ -180,7 +180,7 @@ function detectSourceProject(cwd: string): string | undefined {
180
180
  const vaultPath = resolveVaultPath();
181
181
  // Don't mark notes created from within the vault itself
182
182
  if (cwd.startsWith(vaultPath)) return undefined;
183
- return generateProjectId(cwd);
183
+ return projectIdFor(cwd);
184
184
  } catch {
185
185
  return undefined;
186
186
  }
@@ -24,10 +24,16 @@ export function sessionStart(cwd: string): void {
24
24
  // Never crash hooks
25
25
  }
26
26
 
27
- // One-shot migration to sync layout v2. Idempotent re-run is a no-op.
27
+ // One-shot migration to the current sync layout. Idempotent re-run is a
28
+ // no-op. We also re-trigger when projects.identity=git-remote so a user who
29
+ // flips the flag after the version has stamped still gets project directories
30
+ // renamed to their stable identifier on the next session-start.
28
31
  try {
29
32
  const { readSyncVersion, MINK_SYNC_VERSION } = require("../core/sync");
30
- if (readSyncVersion() < MINK_SYNC_VERSION) {
33
+ const { resolveConfigValue } = require("../core/global-config");
34
+ const identityOn =
35
+ resolveConfigValue("projects.identity").value === "git-remote";
36
+ if (readSyncVersion() < MINK_SYNC_VERSION || identityOn) {
31
37
  const { migrateSyncLayout } = require("./sync-migrate");
32
38
  migrateSyncLayout();
33
39
  }
@@ -25,6 +25,16 @@ import {
25
25
  } from "../core/sync";
26
26
  import { getOrCreateDeviceId } from "../core/device";
27
27
  import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
28
+ import {
29
+ resolveProjectIdentity,
30
+ generateProjectId,
31
+ } from "../core/project-id";
32
+ import {
33
+ getProjectMeta,
34
+ addProjectAlias,
35
+ setProjectPathForDevice,
36
+ } from "../core/project-registry";
37
+ import { resolveConfigValue } from "../core/global-config";
28
38
  import type { FileIndex } from "../types/file-index";
29
39
 
30
40
  const MIGRATE_LOCK = ".sync-migrate.lock";
@@ -210,6 +220,308 @@ function listProjectsNeedingMigration(): string[] {
210
220
  return listProjects().filter(projectNeedsMigration);
211
221
  }
212
222
 
223
+ // ── v3 identity migration ─────────────────────────────────────────────────
224
+ //
225
+ // When `projects.identity = git-remote`, walks every project on disk and:
226
+ // 1. Computes its new identifier from the recorded working-copy path.
227
+ // 2. If the new identifier differs from the on-disk directory name, snapshots
228
+ // the project to a rollback backup, then renames the directory (preferring
229
+ // `git mv` so history is preserved when sync is initialised), records the
230
+ // old identifier as an alias, and lifts the singular `cwd` into the
231
+ // per-device path map keyed by this device.
232
+ // 3. If the working-copy path is missing from the local filesystem (the
233
+ // project's repo was cloned on a different machine), the project is left
234
+ // alone — the device that owns the cwd will handle the rename.
235
+ //
236
+ // Idempotent: re-running after a clean pass walks every project, finds every
237
+ // id matches its directory name, and does nothing.
238
+
239
+ export type IdentityPlanAction = "rename" | "skip-converged" | "skip-no-cwd" | "skip-unchanged";
240
+
241
+ export interface IdentityPlanEntry {
242
+ oldId: string;
243
+ newId: string | null;
244
+ cwd: string | null;
245
+ action: IdentityPlanAction;
246
+ reason?: string;
247
+ }
248
+
249
+ // Walks every project on disk and returns the rename plan without touching it.
250
+ // Backbone for both --dry-run and the real migration so they share logic.
251
+ export function planIdentityMigration(): IdentityPlanEntry[] {
252
+ const plan: IdentityPlanEntry[] = [];
253
+ if (resolveConfigValue("projects.identity").value !== "git-remote") {
254
+ return plan;
255
+ }
256
+
257
+ const projectsRoot = join(minkRoot(), "projects");
258
+ if (!existsSync(projectsRoot)) return plan;
259
+
260
+ let entries: string[];
261
+ try {
262
+ entries = readdirSync(projectsRoot);
263
+ } catch {
264
+ return plan;
265
+ }
266
+
267
+ for (const oldId of entries) {
268
+ const oldProjDir = join(projectsRoot, oldId);
269
+ try {
270
+ if (!statSync(oldProjDir).isDirectory()) continue;
271
+ } catch {
272
+ continue;
273
+ }
274
+
275
+ const meta = getProjectMeta(oldProjDir);
276
+ if (!meta) continue;
277
+
278
+ if (!existsSync(meta.cwd)) {
279
+ plan.push({
280
+ oldId,
281
+ newId: null,
282
+ cwd: meta.cwd,
283
+ action: "skip-no-cwd",
284
+ reason: "working-copy path not reachable on this device",
285
+ });
286
+ continue;
287
+ }
288
+
289
+ let newId: string;
290
+ try {
291
+ newId = resolveProjectIdentity(meta.cwd).id;
292
+ } catch {
293
+ continue;
294
+ }
295
+
296
+ if (newId === oldId) {
297
+ plan.push({ oldId, newId, cwd: meta.cwd, action: "skip-unchanged" });
298
+ continue;
299
+ }
300
+
301
+ const newProjDir = join(projectsRoot, newId);
302
+ if (existsSync(newProjDir)) {
303
+ plan.push({
304
+ oldId,
305
+ newId,
306
+ cwd: meta.cwd,
307
+ action: "skip-converged",
308
+ reason: "destination already exists (from sync); alias-only update",
309
+ });
310
+ continue;
311
+ }
312
+
313
+ plan.push({ oldId, newId, cwd: meta.cwd, action: "rename" });
314
+ }
315
+ return plan;
316
+ }
317
+
318
+ const IDENTITY_BACKUP_DIRNAME = ".identity-rollback";
319
+
320
+ function identityBackupRoot(timestamp: string): string {
321
+ return join(minkRoot(), IDENTITY_BACKUP_DIRNAME, timestamp);
322
+ }
323
+
324
+ function ensureIdentityBackupTimestamp(): string {
325
+ const now = new Date();
326
+ return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(
327
+ now.getUTCDate()
328
+ ).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(
329
+ now.getUTCMinutes()
330
+ ).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
331
+ }
332
+
333
+ // Copies a project directory tree to the rollback backup. Skips `backups/` so
334
+ // nested backups don't double the snapshot size. Returns the backup path or
335
+ // null on failure (caller logs but does not abort migration on backup failure).
336
+ function backupProjectForRollback(srcDir: string, backupDir: string): string | null {
337
+ try {
338
+ mkdirSync(backupDir, { recursive: true });
339
+ copyDirRecursive(srcDir, backupDir, new Set(["backups"]));
340
+ return backupDir;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>): void {
347
+ mkdirSync(dest, { recursive: true });
348
+ const entries = readdirSync(src, { withFileTypes: true });
349
+ for (const entry of entries) {
350
+ if (excludeNames.has(entry.name)) continue;
351
+ const srcPath = join(src, entry.name);
352
+ const destPath = join(dest, entry.name);
353
+ if (entry.isDirectory()) {
354
+ copyDirRecursive(srcPath, destPath, excludeNames);
355
+ } else if (entry.isFile()) {
356
+ writeFileSync(destPath, readFileSync(srcPath));
357
+ }
358
+ }
359
+ }
360
+
361
+ function migrateProjectIdentities(deviceId: string): {
362
+ renamed: number;
363
+ visited: number;
364
+ backupDir: string | null;
365
+ } {
366
+ if (resolveConfigValue("projects.identity").value !== "git-remote") {
367
+ return { renamed: 0, visited: 0, backupDir: null };
368
+ }
369
+
370
+ const plan = planIdentityMigration();
371
+ const willRename = plan.filter((p) => p.action === "rename");
372
+
373
+ // Compute the backup root up-front so all snapshots for this migration pass
374
+ // land in one timestamped directory the user can find and reason about.
375
+ let backupRoot: string | null = null;
376
+ if (willRename.length > 0) {
377
+ backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
378
+ }
379
+
380
+ let renamed = 0;
381
+ let visited = plan.length;
382
+ const projectsRoot = join(minkRoot(), "projects");
383
+
384
+ for (const entry of plan) {
385
+ const oldProjDir = join(projectsRoot, entry.oldId);
386
+
387
+ // Lift cwd into pathsByDevice for every project we can see, even
388
+ // skip-unchanged ones, so older records gain the multi-device shape.
389
+ if (entry.cwd && entry.action !== "skip-no-cwd") {
390
+ try {
391
+ setProjectPathForDevice(oldProjDir, deviceId, entry.cwd);
392
+ } catch {
393
+ // best-effort
394
+ }
395
+ }
396
+
397
+ if (entry.action === "skip-converged" && entry.newId) {
398
+ const newProjDir = join(projectsRoot, entry.newId);
399
+ try {
400
+ addProjectAlias(newProjDir, entry.oldId);
401
+ if (entry.cwd) {
402
+ setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
403
+ }
404
+ } catch {
405
+ // best-effort
406
+ }
407
+ continue;
408
+ }
409
+
410
+ if (entry.action !== "rename" || !entry.newId) continue;
411
+
412
+ // Snapshot the project before the rename so the user can recover if the
413
+ // alias-based rollback ever fails.
414
+ if (backupRoot) {
415
+ backupProjectForRollback(oldProjDir, join(backupRoot, entry.oldId));
416
+ }
417
+
418
+ const newProjDir = join(projectsRoot, entry.newId);
419
+ const moved =
420
+ gitSafe(`mv "${oldProjDir}" "${newProjDir}"`) !== null ||
421
+ (() => {
422
+ try {
423
+ renameSync(oldProjDir, newProjDir);
424
+ return true;
425
+ } catch {
426
+ return false;
427
+ }
428
+ })();
429
+
430
+ if (!moved) continue;
431
+
432
+ try {
433
+ addProjectAlias(newProjDir, entry.oldId);
434
+ if (entry.cwd) {
435
+ setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
436
+ }
437
+ } catch {
438
+ // best-effort
439
+ }
440
+ renamed++;
441
+ }
442
+
443
+ return { renamed, visited, backupDir: backupRoot };
444
+ }
445
+
446
+ // ── v3 identity rollback ──────────────────────────────────────────────────
447
+ //
448
+ // Reverses the most recent identity rename for every project that has at
449
+ // least one alias recorded. Picks the most recently appended alias as the
450
+ // target id, renames the project directory back, and pops that entry from
451
+ // the alias list. Idempotent: a project with no aliases is left alone.
452
+ //
453
+ // This is the primary rollback path. The pre-migration backup is a fallback
454
+ // for when alias-based rollback can't proceed (e.g. metadata corruption).
455
+
456
+ export interface RollbackEntry {
457
+ currentId: string;
458
+ restoredId: string;
459
+ ok: boolean;
460
+ }
461
+
462
+ export function rollbackProjectIdentities(): RollbackEntry[] {
463
+ const results: RollbackEntry[] = [];
464
+ const projectsRoot = join(minkRoot(), "projects");
465
+ if (!existsSync(projectsRoot)) return results;
466
+
467
+ let entries: string[];
468
+ try {
469
+ entries = readdirSync(projectsRoot);
470
+ } catch {
471
+ return results;
472
+ }
473
+
474
+ for (const currentId of entries) {
475
+ const projDir = join(projectsRoot, currentId);
476
+ try {
477
+ if (!statSync(projDir).isDirectory()) continue;
478
+ } catch {
479
+ continue;
480
+ }
481
+
482
+ const meta = getProjectMeta(projDir);
483
+ if (!meta || !meta.aliases || meta.aliases.length === 0) continue;
484
+
485
+ const restoredId = meta.aliases[meta.aliases.length - 1];
486
+ const targetDir = join(projectsRoot, restoredId);
487
+
488
+ if (existsSync(targetDir)) {
489
+ // Refuse to overwrite an existing directory at the target id.
490
+ results.push({ currentId, restoredId, ok: false });
491
+ continue;
492
+ }
493
+
494
+ // Pop the alias before renaming so the resulting on-disk metadata file
495
+ // reflects the rolled-back state even if the rename itself succeeds.
496
+ const remainingAliases = meta.aliases.slice(0, -1);
497
+ const metaPath = join(projDir, "project-meta.json");
498
+ try {
499
+ const raw = safeReadJson(metaPath);
500
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
501
+ const obj = raw as Record<string, unknown>;
502
+ obj.aliases = remainingAliases;
503
+ atomicWriteJson(metaPath, obj);
504
+ }
505
+ } catch {
506
+ // best-effort; rollback continues
507
+ }
508
+
509
+ const moved =
510
+ gitSafe(`mv "${projDir}" "${targetDir}"`) !== null ||
511
+ (() => {
512
+ try {
513
+ renameSync(projDir, targetDir);
514
+ return true;
515
+ } catch {
516
+ return false;
517
+ }
518
+ })();
519
+
520
+ results.push({ currentId, restoredId, ok: moved });
521
+ }
522
+ return results;
523
+ }
524
+
213
525
  export interface MigrateResult {
214
526
  ranMigration: boolean;
215
527
  fromVersion: number;
@@ -221,13 +533,21 @@ export interface MigrateResult {
221
533
  // session-start auto-trigger when readSyncVersion() < MINK_SYNC_VERSION.
222
534
  //
223
535
  // We treat the version marker as a hint, not a gate — a previous partial run
224
- // (interrupted by the budget cap) may have written v2 with projects still
225
- // pending. We re-run as long as any project on disk still has legacy files at
226
- // its top level, regardless of marker.
536
+ // (interrupted by the budget cap) may have written the latest version with
537
+ // projects still pending. We re-run as long as any project on disk still has
538
+ // legacy files at its top level, regardless of marker. The v3 identity step
539
+ // also runs whenever projects.identity=git-remote so a user who flips the
540
+ // flag after the version has already stamped to 3 still gets their projects
541
+ // migrated.
227
542
  export function migrateSyncLayout(): MigrateResult {
228
543
  const fromVersion = readSyncVersion();
229
544
  const pending = listProjectsNeedingMigration();
230
- if (fromVersion >= MINK_SYNC_VERSION && pending.length === 0) {
545
+ const identityMode = resolveConfigValue("projects.identity").value;
546
+ if (
547
+ fromVersion >= MINK_SYNC_VERSION &&
548
+ pending.length === 0 &&
549
+ identityMode !== "git-remote"
550
+ ) {
231
551
  return {
232
552
  ranMigration: false,
233
553
  fromVersion,
@@ -288,6 +608,16 @@ export function migrateSyncLayout(): MigrateResult {
288
608
  }
289
609
  }
290
610
 
611
+ // v3 identity migration: rename per-project directories to their stable
612
+ // git-derived identifier when the user has opted in. Cheap no-op when the
613
+ // flag is off or every project's identifier already matches its directory.
614
+ let identity = { renamed: 0, visited: 0 };
615
+ try {
616
+ identity = migrateProjectIdentities(deviceId);
617
+ } catch {
618
+ // best-effort; never block the rest of migration
619
+ }
620
+
291
621
  // Only stamp the version marker once nothing is left to migrate. If we
292
622
  // still have pending projects, leave the marker as-is so the next session
293
623
  // knows to keep going.
@@ -295,12 +625,16 @@ export function migrateSyncLayout(): MigrateResult {
295
625
  writeSyncVersion(MINK_SYNC_VERSION);
296
626
  }
297
627
 
298
- if (isSyncInitialized() && processed > 0) {
628
+ if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
299
629
  // Skip the lock file — it's part of migration coordination, not state.
300
630
  gitSafe("add -A");
301
631
  gitSafe(`reset HEAD ".sync-migrate.lock"`);
632
+ const summary =
633
+ identity.renamed > 0
634
+ ? `${processed} projects, ${identity.renamed} renamed for identity v3`
635
+ : `${processed} projects`;
302
636
  gitSafe(
303
- `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${processed} projects)"`
637
+ `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
304
638
  );
305
639
  }
306
640
 
@@ -318,7 +652,70 @@ export function migrateSyncLayout(): MigrateResult {
318
652
  }
319
653
  }
320
654
 
321
- export function syncMigrateCommand(): void {
655
+ export function syncMigrateCommand(args: string[] = []): void {
656
+ const dryRun = args.includes("--dry-run");
657
+ const rollback = args.includes("--rollback");
658
+
659
+ if (rollback && dryRun) {
660
+ console.error("[mink] --rollback and --dry-run cannot be combined");
661
+ process.exit(1);
662
+ }
663
+
664
+ if (dryRun) {
665
+ const plan = planIdentityMigration();
666
+ if (plan.length === 0) {
667
+ console.log(
668
+ "[mink] sync migrate --dry-run: no projects to rename (flag is off or no projects on disk)"
669
+ );
670
+ return;
671
+ }
672
+ const renames = plan.filter((p) => p.action === "rename");
673
+ const converged = plan.filter((p) => p.action === "skip-converged");
674
+ const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
675
+ const unchanged = plan.filter((p) => p.action === "skip-unchanged");
676
+
677
+ console.log(
678
+ `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
679
+ );
680
+ for (const p of renames) {
681
+ console.log(` rename: ${p.oldId} → ${p.newId}`);
682
+ }
683
+ for (const p of converged) {
684
+ console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
685
+ }
686
+ for (const p of skippedNoCwd) {
687
+ console.log(` skip: ${p.oldId} — ${p.reason}`);
688
+ }
689
+ return;
690
+ }
691
+
692
+ if (rollback) {
693
+ const results = rollbackProjectIdentities();
694
+ if (results.length === 0) {
695
+ console.log("[mink] sync migrate --rollback: nothing to roll back");
696
+ return;
697
+ }
698
+ const ok = results.filter((r) => r.ok);
699
+ const failed = results.filter((r) => !r.ok);
700
+ console.log(
701
+ `[mink] sync migrate --rollback: ${ok.length} restored, ${failed.length} failed`
702
+ );
703
+ for (const r of ok) {
704
+ console.log(` restored: ${r.currentId} → ${r.restoredId}`);
705
+ }
706
+ for (const r of failed) {
707
+ console.log(
708
+ ` failed: ${r.currentId} → ${r.restoredId} (destination already exists or rename blocked)`
709
+ );
710
+ }
711
+ if (ok.length > 0) {
712
+ console.log(
713
+ "\n[mink] tip: set projects.identity=path-derived to prevent the next session-start from re-migrating"
714
+ );
715
+ }
716
+ return;
717
+ }
718
+
322
719
  const result = migrateSyncLayout();
323
720
  if (!result.ranMigration) {
324
721
  console.log(`[mink] sync migrate: ${result.message ?? "no-op"}`);
@@ -52,14 +52,17 @@ export async function sync(args: string[]): Promise<void> {
52
52
 
53
53
  case "migrate": {
54
54
  const { syncMigrateCommand } = await import("./sync-migrate");
55
- syncMigrateCommand();
55
+ syncMigrateCommand(args.slice(1));
56
56
  return;
57
57
  }
58
58
 
59
59
  default:
60
60
  console.error(`[mink] unknown sync subcommand: ${subcommand}`);
61
61
  console.error(
62
- "Usage: mink sync [init|status|push|pull|pause|resume|disconnect|reconcile|migrate|merge-driver]"
62
+ "Usage: mink sync [init|status|push|pull|pause|resume|disconnect|reconcile|merge-driver]"
63
+ );
64
+ console.error(
65
+ " mink sync migrate [--dry-run|--rollback]"
63
66
  );
64
67
  process.exit(1);
65
68
  }
@@ -35,7 +35,7 @@ import {
35
35
  triggerIngestFile,
36
36
  } from "./dashboard-api";
37
37
  import { listRegisteredProjects, getProjectMeta } from "./project-registry";
38
- import { generateProjectId } from "./project-id";
38
+ import { projectIdFor } from "./project-id";
39
39
  import { runtimeFile, runtimeServe, runtimeSpawn } from "./runtime";
40
40
  import type { StateFileId, StateChangeEvent } from "../types/dashboard";
41
41
  import type { RegisteredProject } from "./project-registry";
@@ -154,10 +154,14 @@ function resolveProjectCwd(
154
154
 
155
155
  // If the requested project matches the currently active project, use it directly
156
156
  // (handles startup projects that may not be in the registry yet)
157
- if (projectId === generateProjectId(defaultCwd)) return defaultCwd;
157
+ if (projectId === projectIdFor(defaultCwd)) return defaultCwd;
158
158
 
159
159
  const projects = listRegisteredProjects();
160
- const match = projects.find((p) => p.id === projectId);
160
+ // Match against primary id first, then walk alias lists so historical
161
+ // dashboard URLs continue routing after a v3 identity migration.
162
+ const match =
163
+ projects.find((p) => p.id === projectId) ??
164
+ projects.find((p) => p.aliases.includes(projectId));
161
165
  if (!match) return null;
162
166
 
163
167
  return match.cwd;
@@ -170,11 +174,11 @@ function getProjectsList(
170
174
  projects: RegisteredProject[];
171
175
  activeProjectId: string;
172
176
  } {
173
- const activeId = generateProjectId(activeCwd);
177
+ const activeId = projectIdFor(activeCwd);
174
178
  const registered = listRegisteredProjects();
175
179
 
176
180
  // Ensure startup project is always in the list
177
- const startupId = generateProjectId(startupCwd);
181
+ const startupId = projectIdFor(startupCwd);
178
182
  const hasStartup = registered.some((p) => p.id === startupId);
179
183
  if (!hasStartup) {
180
184
  const meta = getProjectMeta(projectDir(startupCwd));
@@ -183,6 +187,8 @@ function getProjectsList(
183
187
  cwd: startupCwd,
184
188
  name: meta?.name ?? basename(startupCwd),
185
189
  version: meta?.version ?? "0.1.0",
190
+ aliases: meta?.aliases ?? [],
191
+ pathsByDevice: meta?.pathsByDevice ?? {},
186
192
  });
187
193
  }
188
194
 
@@ -196,6 +202,8 @@ function getProjectsList(
196
202
  cwd: activeCwd,
197
203
  name: meta?.name ?? basename(activeCwd),
198
204
  version: meta?.version ?? "0.1.0",
205
+ aliases: meta?.aliases ?? [],
206
+ pathsByDevice: meta?.pathsByDevice ?? {},
199
207
  });
200
208
  }
201
209
  }