@drewpayment/mink 0.10.1 → 0.11.0-beta.2

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 +1407 -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 +429 -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 +150 -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 → WyN-sdaVY7cZaACRaK7vq}/_buildManifest.js +0 -0
  53. /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WyN-sdaVY7cZaACRaK7vq}/_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.2",
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,326 @@ 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
+ //
252
+ // Accepts an optional `flagOverride` so callers that have already snapshotted
253
+ // `projects.identity` (e.g. migrateSyncLayout, before its git-stash) can pass
254
+ // the snapshot in rather than re-reading from disk inside a stash window where
255
+ // the config file's uncommitted writes are temporarily hidden.
256
+ export function planIdentityMigration(flagOverride?: string): IdentityPlanEntry[] {
257
+ const plan: IdentityPlanEntry[] = [];
258
+ const flag = flagOverride ?? resolveConfigValue("projects.identity").value;
259
+ if (flag !== "git-remote") {
260
+ return plan;
261
+ }
262
+
263
+ const projectsRoot = join(minkRoot(), "projects");
264
+ if (!existsSync(projectsRoot)) return plan;
265
+
266
+ let entries: string[];
267
+ try {
268
+ entries = readdirSync(projectsRoot);
269
+ } catch {
270
+ return plan;
271
+ }
272
+
273
+ for (const oldId of entries) {
274
+ const oldProjDir = join(projectsRoot, oldId);
275
+ try {
276
+ if (!statSync(oldProjDir).isDirectory()) continue;
277
+ } catch {
278
+ continue;
279
+ }
280
+
281
+ const meta = getProjectMeta(oldProjDir);
282
+ if (!meta) continue;
283
+
284
+ if (!existsSync(meta.cwd)) {
285
+ plan.push({
286
+ oldId,
287
+ newId: null,
288
+ cwd: meta.cwd,
289
+ action: "skip-no-cwd",
290
+ reason: "working-copy path not reachable on this device",
291
+ });
292
+ continue;
293
+ }
294
+
295
+ let newId: string;
296
+ try {
297
+ newId = resolveProjectIdentity(
298
+ meta.cwd,
299
+ flag === "git-remote" || flag === "path-derived" ? flag : undefined
300
+ ).id;
301
+ } catch {
302
+ continue;
303
+ }
304
+
305
+ if (newId === oldId) {
306
+ plan.push({ oldId, newId, cwd: meta.cwd, action: "skip-unchanged" });
307
+ continue;
308
+ }
309
+
310
+ const newProjDir = join(projectsRoot, newId);
311
+ if (existsSync(newProjDir)) {
312
+ plan.push({
313
+ oldId,
314
+ newId,
315
+ cwd: meta.cwd,
316
+ action: "skip-converged",
317
+ reason: "destination already exists (from sync); alias-only update",
318
+ });
319
+ continue;
320
+ }
321
+
322
+ plan.push({ oldId, newId, cwd: meta.cwd, action: "rename" });
323
+ }
324
+ return plan;
325
+ }
326
+
327
+ const IDENTITY_BACKUP_DIRNAME = ".identity-rollback";
328
+
329
+ function identityBackupRoot(timestamp: string): string {
330
+ return join(minkRoot(), IDENTITY_BACKUP_DIRNAME, timestamp);
331
+ }
332
+
333
+ function ensureIdentityBackupTimestamp(): string {
334
+ const now = new Date();
335
+ return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(
336
+ now.getUTCDate()
337
+ ).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(
338
+ now.getUTCMinutes()
339
+ ).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
340
+ }
341
+
342
+ // Copies a project directory tree to the rollback backup. Skips `backups/` so
343
+ // nested backups don't double the snapshot size. Returns the backup path or
344
+ // null on failure (caller logs but does not abort migration on backup failure).
345
+ function backupProjectForRollback(srcDir: string, backupDir: string): string | null {
346
+ try {
347
+ mkdirSync(backupDir, { recursive: true });
348
+ copyDirRecursive(srcDir, backupDir, new Set(["backups"]));
349
+ return backupDir;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>): void {
356
+ mkdirSync(dest, { recursive: true });
357
+ const entries = readdirSync(src, { withFileTypes: true });
358
+ for (const entry of entries) {
359
+ if (excludeNames.has(entry.name)) continue;
360
+ const srcPath = join(src, entry.name);
361
+ const destPath = join(dest, entry.name);
362
+ if (entry.isDirectory()) {
363
+ copyDirRecursive(srcPath, destPath, excludeNames);
364
+ } else if (entry.isFile()) {
365
+ writeFileSync(destPath, readFileSync(srcPath));
366
+ }
367
+ }
368
+ }
369
+
370
+ // Accepts the identity-mode value as a parameter so the caller can snapshot it
371
+ // before any disk side-effects (notably the migrating git-stash in
372
+ // migrateSyncLayout, which would hide uncommitted writes to the config file
373
+ // that drives this very decision). Falls back to a fresh read for callers that
374
+ // don't operate inside a stash window (e.g. session-start triggers and the
375
+ // --dry-run path).
376
+ function migrateProjectIdentities(
377
+ deviceId: string,
378
+ flag: string = resolveConfigValue("projects.identity").value
379
+ ): {
380
+ renamed: number;
381
+ visited: number;
382
+ backupDir: string | null;
383
+ } {
384
+ if (flag !== "git-remote") {
385
+ return { renamed: 0, visited: 0, backupDir: null };
386
+ }
387
+
388
+ const plan = planIdentityMigration(flag);
389
+ const willRename = plan.filter((p) => p.action === "rename");
390
+
391
+ // Compute the backup root up-front so all snapshots for this migration pass
392
+ // land in one timestamped directory the user can find and reason about.
393
+ let backupRoot: string | null = null;
394
+ if (willRename.length > 0) {
395
+ backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
396
+ }
397
+
398
+ let renamed = 0;
399
+ let visited = plan.length;
400
+ const projectsRoot = join(minkRoot(), "projects");
401
+
402
+ for (const entry of plan) {
403
+ const oldProjDir = join(projectsRoot, entry.oldId);
404
+
405
+ // Lift cwd into pathsByDevice for every project we can see, even
406
+ // skip-unchanged ones, so older records gain the multi-device shape.
407
+ if (entry.cwd && entry.action !== "skip-no-cwd") {
408
+ try {
409
+ setProjectPathForDevice(oldProjDir, deviceId, entry.cwd);
410
+ } catch {
411
+ // best-effort
412
+ }
413
+ }
414
+
415
+ if (entry.action === "skip-converged" && entry.newId) {
416
+ const newProjDir = join(projectsRoot, entry.newId);
417
+ try {
418
+ addProjectAlias(newProjDir, entry.oldId);
419
+ if (entry.cwd) {
420
+ setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
421
+ }
422
+ } catch {
423
+ // best-effort
424
+ }
425
+ continue;
426
+ }
427
+
428
+ if (entry.action !== "rename" || !entry.newId) continue;
429
+
430
+ // Snapshot the project before the rename so the user can recover if the
431
+ // alias-based rollback ever fails.
432
+ if (backupRoot) {
433
+ backupProjectForRollback(oldProjDir, join(backupRoot, entry.oldId));
434
+ }
435
+
436
+ const newProjDir = join(projectsRoot, entry.newId);
437
+ const moved =
438
+ gitSafe(`mv "${oldProjDir}" "${newProjDir}"`) !== null ||
439
+ (() => {
440
+ try {
441
+ renameSync(oldProjDir, newProjDir);
442
+ return true;
443
+ } catch {
444
+ return false;
445
+ }
446
+ })();
447
+
448
+ if (!moved) continue;
449
+
450
+ try {
451
+ addProjectAlias(newProjDir, entry.oldId);
452
+ if (entry.cwd) {
453
+ setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
454
+ }
455
+ } catch {
456
+ // best-effort
457
+ }
458
+ renamed++;
459
+ }
460
+
461
+ return { renamed, visited, backupDir: backupRoot };
462
+ }
463
+
464
+ // ── v3 identity rollback ──────────────────────────────────────────────────
465
+ //
466
+ // Reverses the most recent identity rename for every project that has at
467
+ // least one alias recorded. Picks the most recently appended alias as the
468
+ // target id, renames the project directory back, and pops that entry from
469
+ // the alias list. Idempotent: a project with no aliases is left alone.
470
+ //
471
+ // This is the primary rollback path. The pre-migration backup is a fallback
472
+ // for when alias-based rollback can't proceed (e.g. metadata corruption).
473
+
474
+ export interface RollbackEntry {
475
+ currentId: string;
476
+ restoredId: string;
477
+ ok: boolean;
478
+ }
479
+
480
+ export function rollbackProjectIdentities(): RollbackEntry[] {
481
+ const results: RollbackEntry[] = [];
482
+ const projectsRoot = join(minkRoot(), "projects");
483
+ if (!existsSync(projectsRoot)) return results;
484
+
485
+ let entries: string[];
486
+ try {
487
+ entries = readdirSync(projectsRoot);
488
+ } catch {
489
+ return results;
490
+ }
491
+
492
+ for (const currentId of entries) {
493
+ const projDir = join(projectsRoot, currentId);
494
+ try {
495
+ if (!statSync(projDir).isDirectory()) continue;
496
+ } catch {
497
+ continue;
498
+ }
499
+
500
+ const meta = getProjectMeta(projDir);
501
+ if (!meta || !meta.aliases || meta.aliases.length === 0) continue;
502
+
503
+ const restoredId = meta.aliases[meta.aliases.length - 1];
504
+ const targetDir = join(projectsRoot, restoredId);
505
+
506
+ if (existsSync(targetDir)) {
507
+ // Refuse to overwrite an existing directory at the target id.
508
+ results.push({ currentId, restoredId, ok: false });
509
+ continue;
510
+ }
511
+
512
+ // Pop the alias before renaming so the resulting on-disk metadata file
513
+ // reflects the rolled-back state even if the rename itself succeeds.
514
+ const remainingAliases = meta.aliases.slice(0, -1);
515
+ const metaPath = join(projDir, "project-meta.json");
516
+ try {
517
+ const raw = safeReadJson(metaPath);
518
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
519
+ const obj = raw as Record<string, unknown>;
520
+ obj.aliases = remainingAliases;
521
+ atomicWriteJson(metaPath, obj);
522
+ }
523
+ } catch {
524
+ // best-effort; rollback continues
525
+ }
526
+
527
+ const moved =
528
+ gitSafe(`mv "${projDir}" "${targetDir}"`) !== null ||
529
+ (() => {
530
+ try {
531
+ renameSync(projDir, targetDir);
532
+ return true;
533
+ } catch {
534
+ return false;
535
+ }
536
+ })();
537
+
538
+ results.push({ currentId, restoredId, ok: moved });
539
+ }
540
+ return results;
541
+ }
542
+
213
543
  export interface MigrateResult {
214
544
  ranMigration: boolean;
215
545
  fromVersion: number;
@@ -221,13 +551,26 @@ export interface MigrateResult {
221
551
  // session-start auto-trigger when readSyncVersion() < MINK_SYNC_VERSION.
222
552
  //
223
553
  // 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.
554
+ // (interrupted by the budget cap) may have written the latest version with
555
+ // projects still pending. We re-run as long as any project on disk still has
556
+ // legacy files at its top level, regardless of marker. The v3 identity step
557
+ // also runs whenever projects.identity=git-remote so a user who flips the
558
+ // flag after the version has already stamped to 3 still gets their projects
559
+ // migrated.
227
560
  export function migrateSyncLayout(): MigrateResult {
228
561
  const fromVersion = readSyncVersion();
229
562
  const pending = listProjectsNeedingMigration();
230
- if (fromVersion >= MINK_SYNC_VERSION && pending.length === 0) {
563
+ // Snapshot the identity mode BEFORE the migrating stash below. The stash
564
+ // hides any uncommitted edits to ~/.mink/config — including the very
565
+ // `projects.identity = git-remote` write that should be driving this
566
+ // migration. Reading the flag after the stash would see the stale,
567
+ // last-committed config and the v3 identity step would no-op.
568
+ const identityMode = resolveConfigValue("projects.identity").value;
569
+ if (
570
+ fromVersion >= MINK_SYNC_VERSION &&
571
+ pending.length === 0 &&
572
+ identityMode !== "git-remote"
573
+ ) {
231
574
  return {
232
575
  ranMigration: false,
233
576
  fromVersion,
@@ -288,6 +631,18 @@ export function migrateSyncLayout(): MigrateResult {
288
631
  }
289
632
  }
290
633
 
634
+ // v3 identity migration: rename per-project directories to their stable
635
+ // git-derived identifier when the user has opted in. Cheap no-op when the
636
+ // flag is off or every project's identifier already matches its directory.
637
+ // Pass the pre-stash snapshot of identityMode so we don't re-read the
638
+ // config from a stash-hidden working tree.
639
+ let identity = { renamed: 0, visited: 0 };
640
+ try {
641
+ identity = migrateProjectIdentities(deviceId, identityMode);
642
+ } catch {
643
+ // best-effort; never block the rest of migration
644
+ }
645
+
291
646
  // Only stamp the version marker once nothing is left to migrate. If we
292
647
  // still have pending projects, leave the marker as-is so the next session
293
648
  // knows to keep going.
@@ -295,12 +650,16 @@ export function migrateSyncLayout(): MigrateResult {
295
650
  writeSyncVersion(MINK_SYNC_VERSION);
296
651
  }
297
652
 
298
- if (isSyncInitialized() && processed > 0) {
653
+ if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
299
654
  // Skip the lock file — it's part of migration coordination, not state.
300
655
  gitSafe("add -A");
301
656
  gitSafe(`reset HEAD ".sync-migrate.lock"`);
657
+ const summary =
658
+ identity.renamed > 0
659
+ ? `${processed} projects, ${identity.renamed} renamed for identity v3`
660
+ : `${processed} projects`;
302
661
  gitSafe(
303
- `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${processed} projects)"`
662
+ `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
304
663
  );
305
664
  }
306
665
 
@@ -318,7 +677,70 @@ export function migrateSyncLayout(): MigrateResult {
318
677
  }
319
678
  }
320
679
 
321
- export function syncMigrateCommand(): void {
680
+ export function syncMigrateCommand(args: string[] = []): void {
681
+ const dryRun = args.includes("--dry-run");
682
+ const rollback = args.includes("--rollback");
683
+
684
+ if (rollback && dryRun) {
685
+ console.error("[mink] --rollback and --dry-run cannot be combined");
686
+ process.exit(1);
687
+ }
688
+
689
+ if (dryRun) {
690
+ const plan = planIdentityMigration();
691
+ if (plan.length === 0) {
692
+ console.log(
693
+ "[mink] sync migrate --dry-run: no projects to rename (flag is off or no projects on disk)"
694
+ );
695
+ return;
696
+ }
697
+ const renames = plan.filter((p) => p.action === "rename");
698
+ const converged = plan.filter((p) => p.action === "skip-converged");
699
+ const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
700
+ const unchanged = plan.filter((p) => p.action === "skip-unchanged");
701
+
702
+ console.log(
703
+ `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
704
+ );
705
+ for (const p of renames) {
706
+ console.log(` rename: ${p.oldId} → ${p.newId}`);
707
+ }
708
+ for (const p of converged) {
709
+ console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
710
+ }
711
+ for (const p of skippedNoCwd) {
712
+ console.log(` skip: ${p.oldId} — ${p.reason}`);
713
+ }
714
+ return;
715
+ }
716
+
717
+ if (rollback) {
718
+ const results = rollbackProjectIdentities();
719
+ if (results.length === 0) {
720
+ console.log("[mink] sync migrate --rollback: nothing to roll back");
721
+ return;
722
+ }
723
+ const ok = results.filter((r) => r.ok);
724
+ const failed = results.filter((r) => !r.ok);
725
+ console.log(
726
+ `[mink] sync migrate --rollback: ${ok.length} restored, ${failed.length} failed`
727
+ );
728
+ for (const r of ok) {
729
+ console.log(` restored: ${r.currentId} → ${r.restoredId}`);
730
+ }
731
+ for (const r of failed) {
732
+ console.log(
733
+ ` failed: ${r.currentId} → ${r.restoredId} (destination already exists or rename blocked)`
734
+ );
735
+ }
736
+ if (ok.length > 0) {
737
+ console.log(
738
+ "\n[mink] tip: set projects.identity=path-derived to prevent the next session-start from re-migrating"
739
+ );
740
+ }
741
+ return;
742
+ }
743
+
322
744
  const result = migrateSyncLayout();
323
745
  if (!result.ranMigration) {
324
746
  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
  }