@nolto/cli 0.7.1 → 0.8.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 (3) hide show
  1. package/README.md +16 -3
  2. package/dist/index.js +771 -426
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { createRequire as createRequire3 } from "module";
5
5
  import { fileURLToPath as fileURLToPath4 } from "url";
6
- import path13 from "path";
6
+ import path15 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -36,6 +36,38 @@ function mapHttpStatusToCliError(status, opts = {}) {
36
36
  status
37
37
  );
38
38
  }
39
+ if (status === 426) {
40
+ return new CliError(
41
+ opts.serverMessage ?? "Nolto CLI is outdated.",
42
+ 2,
43
+ "Run `nolto update`, then retry.",
44
+ status
45
+ );
46
+ }
47
+ if (status === 409 && opts.serverError === "repo_unbound") {
48
+ return new CliError(
49
+ opts.serverMessage ?? "This project is not bound to a repository.",
50
+ 2,
51
+ "Ask the project owner to run nolto sync in the correct repository first.",
52
+ status
53
+ );
54
+ }
55
+ if (status === 409 && opts.serverError === "repo_mismatch") {
56
+ return new CliError(
57
+ opts.serverMessage ?? "This project is bound to a different repository.",
58
+ 2,
59
+ "Owner: run `nolto link --rebind` inside the repository that should own this project.",
60
+ status
61
+ );
62
+ }
63
+ if (status === 429 && opts.serverError === "rebind_cooldown") {
64
+ return new CliError(
65
+ opts.serverMessage ?? "Rebind cooldown active.",
66
+ 4,
67
+ void 0,
68
+ status
69
+ );
70
+ }
39
71
  if (status === 402) {
40
72
  const hint = opts.upgradeUrl != null ? `Upgrade: ${opts.upgradeUrl}` : void 0;
41
73
  return new CliError(
@@ -157,9 +189,9 @@ async function mergeJsonFile(filePath, patch) {
157
189
  }
158
190
  }
159
191
  const merged = { ...existing, ...patch };
160
- const { chmod } = await import("fs/promises");
192
+ const { chmod: chmod2 } = await import("fs/promises");
161
193
  await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n", { mode: 420 });
162
- await chmod(filePath, 420);
194
+ await chmod2(filePath, 420);
163
195
  }
164
196
  async function writeRepoBinding(root, projectId) {
165
197
  await mergeJsonFile(path.join(root, "nolto.json"), { projectId });
@@ -282,11 +314,11 @@ function maskToken(token) {
282
314
  function createHttpClient(opts) {
283
315
  const { baseUrl, version, token } = opts;
284
316
  const base = baseUrl.replace(/\/+$/, "");
285
- async function request(method, path14, body) {
286
- if (!path14.startsWith("/api/")) {
287
- throw new CliError(`HTTP client path must start with /api/, got: ${path14}`, 2);
317
+ async function request(method, path16, body) {
318
+ if (!path16.startsWith("/api/")) {
319
+ throw new CliError(`HTTP client path must start with /api/, got: ${path16}`, 2);
288
320
  }
289
- const url = `${base}${path14}`;
321
+ const url = `${base}${path16}`;
290
322
  const headers = {
291
323
  "Content-Type": "application/json",
292
324
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -345,11 +377,13 @@ import { Command } from "commander";
345
377
  import readline from "readline/promises";
346
378
  import { createRequire } from "module";
347
379
  import { fileURLToPath as fileURLToPath2 } from "url";
348
- import path6 from "path";
380
+ import os3 from "os";
381
+ import path8 from "path";
349
382
  import fs from "fs";
350
383
 
351
384
  // src/commands/link.ts
352
- import path2 from "path";
385
+ import os2 from "os";
386
+ import path5 from "path";
353
387
  import { statSync as statSync2 } from "fs";
354
388
 
355
389
  // src/output.ts
@@ -403,6 +437,316 @@ function formatValue(value) {
403
437
  return JSON.stringify(value, null, 2);
404
438
  }
405
439
 
440
+ // src/registry.ts
441
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, rename, unlink } from "fs/promises";
442
+ import { randomUUID } from "crypto";
443
+ import path2 from "path";
444
+ import { z as z2 } from "zod";
445
+ var registrySchema = z2.object({
446
+ schemaVersion: z2.literal(1),
447
+ repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
448
+ }).passthrough();
449
+ function getRegistryPath(env) {
450
+ return path2.join(getConfigDir(env), "registry.json");
451
+ }
452
+ async function loadRegistry(filePath) {
453
+ let raw;
454
+ try {
455
+ raw = await readFile2(filePath, "utf8");
456
+ } catch (err) {
457
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
458
+ if (code === "ENOENT") {
459
+ return { schemaVersion: 1, repos: [] };
460
+ }
461
+ throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
462
+ }
463
+ let parsed;
464
+ try {
465
+ parsed = JSON.parse(raw);
466
+ } catch {
467
+ throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
468
+ }
469
+ const result = registrySchema.safeParse(parsed);
470
+ if (!result.success) {
471
+ const issue = result.error.issues[0];
472
+ const fieldPath = issue?.path.join(".") ?? "";
473
+ throw new CliError(
474
+ `Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
475
+ 2
476
+ );
477
+ }
478
+ return result.data;
479
+ }
480
+ async function addRepoToRegistry(filePath, repoRoot) {
481
+ const registry = await loadRegistry(filePath);
482
+ if (registry.repos.some((repo) => repo.root === repoRoot)) {
483
+ return { added: false, registry };
484
+ }
485
+ const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
486
+ await mkdir2(path2.dirname(filePath), { recursive: true, mode: 448 });
487
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
488
+ try {
489
+ await writeFile2(tempPath, JSON.stringify(updated, null, 2) + "\n", "utf8");
490
+ await rename(tempPath, filePath);
491
+ } catch (err) {
492
+ await unlink(tempPath).catch(() => void 0);
493
+ throw err;
494
+ }
495
+ return { added: true, registry: updated };
496
+ }
497
+
498
+ // src/repo-identity.ts
499
+ import { execFile } from "child_process";
500
+ import { realpathSync } from "fs";
501
+ import path4 from "path";
502
+
503
+ // ../roadmap-schema/src/repo-identity.ts
504
+ var HOSTED_LOWERCASE_PATH = /* @__PURE__ */ new Set(["github.com", "gitlab.com", "bitbucket.org"]);
505
+ function normalizeRemote(raw) {
506
+ let s = raw.trim();
507
+ if (s.length === 0) return null;
508
+ if (s.startsWith("file://") || s.startsWith("/") || s.startsWith(".") || /^[A-Za-z]:[\\/]/.test(s)) return null;
509
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(s);
510
+ const hadScheme = scheme !== null;
511
+ if (scheme !== null) s = s.slice(scheme[0].length);
512
+ const authorityEnd = s.indexOf("/");
513
+ const lastAt = s.lastIndexOf("@", authorityEnd === -1 ? s.length - 1 : authorityEnd - 1);
514
+ if (lastAt !== -1) s = s.slice(lastAt + 1);
515
+ const scpLike = hadScheme ? null : /^([^/:]+):(.+)$/.exec(s);
516
+ if (scpLike !== null) {
517
+ s = `${scpLike[1]}/${scpLike[2]}`;
518
+ }
519
+ const firstSlash = s.indexOf("/");
520
+ if (firstSlash <= 0) return null;
521
+ let host = s.slice(0, firstSlash).toLowerCase();
522
+ let path16 = s.slice(firstSlash + 1);
523
+ if (hadScheme) host = host.replace(/:\d+$/, "");
524
+ path16 = path16.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
525
+ if (path16.length === 0) return null;
526
+ if (HOSTED_LOWERCASE_PATH.has(host)) path16 = path16.toLowerCase();
527
+ return `${host}/${path16}`;
528
+ }
529
+
530
+ // ../roadmap-schema/src/index.ts
531
+ var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
532
+ var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
533
+ var ALLOWED_KEYS = {
534
+ roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
535
+ project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
536
+ phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
537
+ task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
538
+ };
539
+ function isRecord(value) {
540
+ return value != null && typeof value === "object" && !Array.isArray(value);
541
+ }
542
+ function checkKeys(value, allowed, at, errors) {
543
+ if (!isRecord(value)) return;
544
+ for (const key of Object.keys(value)) {
545
+ if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
546
+ }
547
+ }
548
+ function checkPlan(value, at, errors) {
549
+ if (value === void 0) return;
550
+ if (typeof value !== "string" || value.length === 0) {
551
+ errors.push(`${at}.plan must be a non-empty string.`);
552
+ }
553
+ }
554
+ function derivePhaseStatus(phase) {
555
+ if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
556
+ if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
557
+ if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
558
+ if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
559
+ return "todo";
560
+ }
561
+ function validateRoadmap(value) {
562
+ const errors = [];
563
+ const warnings = [];
564
+ if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
565
+ checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
566
+ if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
567
+ errors.push("schemaVersion must be 1 or 2.");
568
+ } else if (value["schemaVersion"] === 1) {
569
+ warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
570
+ }
571
+ const project = value["project"];
572
+ checkKeys(project, ALLOWED_KEYS.project, "project", errors);
573
+ const projectRecord = isRecord(project) ? project : {};
574
+ if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
575
+ if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
576
+ if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
577
+ if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
578
+ if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
579
+ if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
580
+ const allIds = /* @__PURE__ */ new Set();
581
+ const tasks = /* @__PURE__ */ new Map();
582
+ const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
583
+ for (const [phaseIndex, rawPhase] of phases.entries()) {
584
+ const at = `phases[${phaseIndex}]`;
585
+ checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
586
+ const phase = isRecord(rawPhase) ? rawPhase : {};
587
+ const phaseId = String(phase["id"] ?? "");
588
+ if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
589
+ if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
590
+ allIds.add(phaseId);
591
+ if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
592
+ if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
593
+ checkPlan(phase["plan"], at, errors);
594
+ if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
595
+ const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
596
+ for (const [taskIndex, rawTask] of rawTasks.entries()) {
597
+ const taskAt = `${at}.tasks[${taskIndex}]`;
598
+ checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
599
+ const task = isRecord(rawTask) ? rawTask : {};
600
+ const taskId = String(task["id"] ?? "");
601
+ if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
602
+ if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
603
+ allIds.add(taskId);
604
+ tasks.set(taskId, task);
605
+ if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
606
+ if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
607
+ checkPlan(task["plan"], taskAt, errors);
608
+ if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
609
+ if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
610
+ if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
611
+ }
612
+ if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
613
+ const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
614
+ if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
615
+ }
616
+ }
617
+ for (const task of tasks.values()) {
618
+ for (const dependency of task.dependsOn ?? []) {
619
+ if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
620
+ if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
621
+ }
622
+ }
623
+ const currentTaskId = value["currentTaskId"];
624
+ if (currentTaskId !== null && currentTaskId !== void 0) {
625
+ const current = tasks.get(String(currentTaskId));
626
+ if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
627
+ else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
628
+ }
629
+ return { errors, warnings };
630
+ }
631
+ function deriveTaskStats(roadmap) {
632
+ let done = 0;
633
+ let blocked = 0;
634
+ let inProgress = 0;
635
+ let todo = 0;
636
+ for (const phase of roadmap.phases) {
637
+ for (const task of phase.tasks) {
638
+ if (task.status === "done") done += 1;
639
+ else if (task.status === "blocked") blocked += 1;
640
+ else if (task.status === "in-progress") inProgress += 1;
641
+ else todo += 1;
642
+ }
643
+ }
644
+ const total = done + blocked + inProgress + todo;
645
+ return {
646
+ total,
647
+ done,
648
+ blocked,
649
+ inProgress,
650
+ todo,
651
+ progressPct: total > 0 ? Math.round(done / total * 100) : 0
652
+ };
653
+ }
654
+
655
+ // src/machine-id.ts
656
+ import { chmod, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
657
+ import { randomUUID as randomUUID2 } from "crypto";
658
+ import path3 from "path";
659
+ var inFlightByConfigDir = /* @__PURE__ */ new Map();
660
+ function parseMachineId(raw, file) {
661
+ let parsed;
662
+ try {
663
+ parsed = JSON.parse(raw);
664
+ } catch {
665
+ throw new CliError(`Malformed JSON in ${file}. Remove the file to regenerate it.`, 2);
666
+ }
667
+ const id = parsed?.machineId;
668
+ if (typeof id !== "string" || id.length === 0) {
669
+ throw new CliError(`Invalid ${file}: missing machineId. Remove the file to regenerate it.`, 2);
670
+ }
671
+ return id;
672
+ }
673
+ async function loadOrCreate(configDir) {
674
+ const file = path3.join(configDir, "machine.json");
675
+ let raw = null;
676
+ try {
677
+ raw = await readFile3(file, "utf8");
678
+ } catch (err) {
679
+ const code = err.code;
680
+ if (code !== "ENOENT") {
681
+ throw new CliError(`Cannot read ${file}: ${String(err)}`, 2);
682
+ }
683
+ }
684
+ if (raw !== null) {
685
+ return parseMachineId(raw, file);
686
+ }
687
+ const machineId = randomUUID2();
688
+ await mkdir3(configDir, { recursive: true, mode: 448 });
689
+ try {
690
+ await writeFile3(file, `${JSON.stringify({ machineId }, null, 2)}
691
+ `, {
692
+ flag: "wx",
693
+ mode: 384
694
+ });
695
+ } catch (err) {
696
+ if (err.code === "EEXIST") {
697
+ return parseMachineId(await readFile3(file, "utf8"), file);
698
+ }
699
+ throw new CliError(`Cannot write ${file}: ${String(err)}`, 2);
700
+ }
701
+ await chmod(file, 384);
702
+ return machineId;
703
+ }
704
+ function loadOrCreateMachineId(configDir) {
705
+ const key = path3.resolve(configDir);
706
+ const existing = inFlightByConfigDir.get(key);
707
+ if (existing !== void 0) return existing;
708
+ const pending = loadOrCreate(key).finally(() => {
709
+ if (inFlightByConfigDir.get(key) === pending) inFlightByConfigDir.delete(key);
710
+ });
711
+ inFlightByConfigDir.set(key, pending);
712
+ return pending;
713
+ }
714
+
715
+ // src/repo-identity.ts
716
+ function defaultGetRemoteUrl(root) {
717
+ return new Promise((resolve) => {
718
+ execFile(
719
+ "git",
720
+ ["-C", root, "remote", "get-url", "origin"],
721
+ { timeout: 5e3 },
722
+ (err, stdout) => {
723
+ if (err) return resolve(null);
724
+ const line = stdout.split(/\r?\n/)[0]?.trim() ?? "";
725
+ resolve(line.length > 0 ? line : null);
726
+ }
727
+ );
728
+ });
729
+ }
730
+ async function resolveRepoIdentity(root, deps) {
731
+ const remote = await deps.getRemoteUrl(root);
732
+ const normalized = remote === null ? null : normalizeRemote(remote);
733
+ if (normalized !== null) return { kind: "remote", value: normalized };
734
+ const machineId = await deps.machineId();
735
+ let canonicalRoot;
736
+ try {
737
+ canonicalRoot = realpathSync(root);
738
+ } catch {
739
+ canonicalRoot = path4.resolve(root);
740
+ }
741
+ return { kind: "local", value: `${machineId}:${canonicalRoot}` };
742
+ }
743
+ function makeRepoIdentityResolver(deps) {
744
+ return deps.repoIdentity ?? ((root) => resolveRepoIdentity(root, {
745
+ getRemoteUrl: defaultGetRemoteUrl,
746
+ machineId: () => loadOrCreateMachineId(getConfigDir(process.env))
747
+ }));
748
+ }
749
+
406
750
  // src/commands/link.ts
407
751
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
408
752
  function resolveStartDir(env, cwd) {
@@ -411,7 +755,7 @@ function resolveStartDir(env, cwd) {
411
755
  }
412
756
  function dirHasGit(dir) {
413
757
  try {
414
- statSync2(path2.join(dir, ".git"));
758
+ statSync2(path5.join(dir, ".git"));
415
759
  return true;
416
760
  } catch {
417
761
  return false;
@@ -423,18 +767,26 @@ function findRepoRoot(startDir, hasGit = dirHasGit) {
423
767
  if (hasGit(current)) {
424
768
  return { root: current, foundGit: true };
425
769
  }
426
- const parent = path2.dirname(current);
770
+ const parent = path5.dirname(current);
427
771
  if (parent === current) break;
428
772
  current = parent;
429
773
  }
430
774
  return { root: startDir, foundGit: false };
431
775
  }
776
+ function isHomeDirectory(dir, home = os2.homedir()) {
777
+ return path5.resolve(dir) === path5.resolve(home);
778
+ }
432
779
  async function handleShow(deps, projectBindingPath, mode2) {
780
+ const startDir = resolveStartDir(process.env, process.cwd());
781
+ const root = findRepoRoot(startDir).root;
782
+ const repoIdentity = await makeRepoIdentityResolver(deps)(root);
433
783
  if (projectBindingPath == null) {
434
784
  if (mode2 === "json") {
435
- printResult({ bound: false, projectBindingPath: null }, mode2);
785
+ printResult({ bound: false, projectBindingPath: null, repoIdentity }, mode2);
436
786
  } else {
437
787
  process.stdout.write("No nolto.json binding found in this directory tree.\n");
788
+ process.stdout.write(`repoIdentity : ${repoIdentity.kind}:${repoIdentity.value}
789
+ `);
438
790
  }
439
791
  return;
440
792
  }
@@ -447,7 +799,8 @@ async function handleShow(deps, projectBindingPath, mode2) {
447
799
  bound: binding != null,
448
800
  projectId: binding?.projectId ?? null,
449
801
  projectBindingPath,
450
- source: deps.settings.source.project === "repo" ? "repo" : "file"
802
+ source: deps.settings.source.project === "repo" ? "repo" : "file",
803
+ repoIdentity
451
804
  }, mode2);
452
805
  } else {
453
806
  if (binding == null) {
@@ -460,15 +813,33 @@ async function handleShow(deps, projectBindingPath, mode2) {
460
813
  `);
461
814
  const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
462
815
  process.stdout.write(`source : ${active}
816
+ `);
817
+ process.stdout.write(`repoIdentity : ${repoIdentity.kind}:${repoIdentity.value}
463
818
  `);
464
819
  }
465
820
  }
466
821
  }
822
+ async function handleRebind(deps, projectId, root, mode2) {
823
+ if (!UUID_RE.test(projectId)) {
824
+ throw new CliError(
825
+ `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
826
+ 2
827
+ );
828
+ }
829
+ const repoIdentity = await makeRepoIdentityResolver(deps)(root);
830
+ await deps.http.post(`/api/projects/${projectId}/repo-binding`, { repoIdentity });
831
+ if (mode2 === "json") {
832
+ printResult({ rebound: true, projectId, repoIdentity }, mode2);
833
+ } else {
834
+ process.stdout.write(`Rebound project ${projectId} to ${repoIdentity.kind}:${repoIdentity.value}.
835
+ `);
836
+ }
837
+ }
467
838
  async function handleUnlink(projectBindingPath, mode2) {
468
- const { readFile: readFile7, writeFile: writeFile6, chmod } = await import("fs/promises");
839
+ const { readFile: readFile8, writeFile: writeFile7, chmod: chmod2 } = await import("fs/promises");
469
840
  let existing = {};
470
841
  try {
471
- const raw = await readFile7(projectBindingPath, "utf8");
842
+ const raw = await readFile8(projectBindingPath, "utf8");
472
843
  const parsed = JSON.parse(raw);
473
844
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
474
845
  throw new CliError(
@@ -483,8 +854,8 @@ async function handleUnlink(projectBindingPath, mode2) {
483
854
  }
484
855
  const { projectId: _removed, ...rest } = existing;
485
856
  void _removed;
486
- await writeFile6(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
487
- await chmod(projectBindingPath, 420);
857
+ await writeFile7(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
858
+ await chmod2(projectBindingPath, 420);
488
859
  if (mode2 === "json") {
489
860
  printResult({ unlinked: true, projectBindingPath }, mode2);
490
861
  } else {
@@ -538,21 +909,43 @@ Proceeding anyway \u2014 verify the ID is correct.
538
909
  );
539
910
  }
540
911
  await writeRepoBinding(root, projectId);
541
- const writtenPath = path2.join(root, "nolto.json");
912
+ const writtenPath = path5.join(root, "nolto.json");
913
+ let registryAdded = false;
914
+ if (isHomeDirectory(root)) {
915
+ process.stderr.write(`Warning: refusing to add home directory ${root} to the watch registry.
916
+ `);
917
+ } else {
918
+ try {
919
+ registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
920
+ } catch (err) {
921
+ const message = err instanceof Error ? err.message : String(err);
922
+ process.stderr.write(`Warning: could not update watch registry: ${message}
923
+ `);
924
+ }
925
+ }
542
926
  if (mode2 === "json") {
543
- printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
927
+ printResult({
928
+ linked: true,
929
+ projectId,
930
+ projectBindingPath: writtenPath,
931
+ registryAdded
932
+ }, mode2);
544
933
  } else {
545
934
  process.stdout.write(
546
935
  `Linked this repo to project ${projectId} (wrote ${writtenPath}).
547
936
  Commit nolto.json to share the binding with your team.
548
937
  `
549
938
  );
939
+ if (registryAdded) {
940
+ process.stdout.write(`watch registry: added ${root}
941
+ `);
942
+ }
550
943
  }
551
944
  }
552
945
  function register(program, deps) {
553
946
  const cmd = program.command("link [projectId]").description(
554
- "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --unlink Remove the projectId from nolto.json"
555
- ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
947
+ "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --rebind Rebind the project to this repository\n nolto link --unlink Remove the projectId from nolto.json"
948
+ ).option("--show", "Show the current repo binding (path + projectId + source)").option("--rebind", "Rebind the project to this repository (owner only, once per 7 days)").option("--unlink", "Remove the projectId key from nolto.json");
556
949
  cmd.action(async (projectId) => {
557
950
  const { output } = deps;
558
951
  const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
@@ -572,12 +965,23 @@ function register(program, deps) {
572
965
  await handleUnlink(projectBindingPath, mode2);
573
966
  return;
574
967
  }
968
+ if (cmd.opts()["rebind"]) {
969
+ const boundProjectId = deps.repoBinding?.binding?.projectId;
970
+ const effectiveProjectId = projectId ?? boundProjectId;
971
+ if (effectiveProjectId == null) {
972
+ throw new CliError("No project binding. Run `nolto link <projectId>` first.", 2);
973
+ }
974
+ const startDir = resolveStartDir(process.env, process.cwd());
975
+ const root = findRepoRoot(startDir).root;
976
+ await handleRebind(deps, effectiveProjectId, root, mode2);
977
+ return;
978
+ }
575
979
  if (projectId == null || projectId.trim().length === 0) {
576
980
  if (bindingError != null) {
577
981
  throw invalidBindingError(projectBindingPath, bindingError);
578
982
  }
579
983
  throw new CliError(
580
- "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
984
+ "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --rebind to rebind it, --unlink to remove it.",
581
985
  2
582
986
  );
583
987
  }
@@ -592,113 +996,87 @@ function invalidBindingError(projectBindingPath, error) {
592
996
  }
593
997
 
594
998
  // src/skill-install.ts
595
- import { cp, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
999
+ import { cp, mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4, rm } from "fs/promises";
596
1000
  import { existsSync } from "fs";
597
- import path3 from "path";
1001
+ import path6 from "path";
598
1002
  import { fileURLToPath } from "url";
599
- var __dirname = path3.dirname(fileURLToPath(import.meta.url));
1003
+ var __dirname = path6.dirname(fileURLToPath(import.meta.url));
600
1004
  function resolveSkillSourceDir() {
601
1005
  const candidates = [
602
- path3.resolve(__dirname, "skill/roadmap-progress"),
1006
+ path6.resolve(__dirname, "skill/roadmap-progress"),
603
1007
  // bundled: dist/skill/...
604
- path3.resolve(__dirname, "../../../skills/roadmap-progress")
1008
+ path6.resolve(__dirname, "../../../skills/roadmap-progress")
605
1009
  // source: <repo>/skills/...
606
1010
  ];
607
1011
  for (const candidate of candidates) {
608
- if (existsSync(path3.join(candidate, "SKILL.md"))) return candidate;
1012
+ if (existsSync(path6.join(candidate, "SKILL.md"))) return candidate;
609
1013
  }
610
1014
  throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
611
1015
  }
612
1016
  var VERSION_MARKER = ".nolto-skill-version";
1017
+ async function checkSkillVersionDrift(repoRoot, cliVersion) {
1018
+ const skillDirs = [
1019
+ path6.join(repoRoot, ".claude", "skills", "roadmap-progress"),
1020
+ path6.join(repoRoot, ".agents", "skills", "roadmap-progress")
1021
+ ];
1022
+ const drifted = [];
1023
+ for (const dir of skillDirs) {
1024
+ if (!existsSync(dir)) continue;
1025
+ let installed;
1026
+ try {
1027
+ const marker = (await readFile4(path6.join(dir, VERSION_MARKER), "utf8")).trim();
1028
+ installed = marker.length > 0 ? marker : null;
1029
+ } catch {
1030
+ installed = null;
1031
+ }
1032
+ if (installed !== cliVersion) drifted.push({ dir, installed });
1033
+ }
1034
+ return drifted;
1035
+ }
1036
+ function formatSkillVersionDriftWarning(repoRoot, cliVersion, drifted) {
1037
+ const dirs = drifted.map(({ dir }) => path6.relative(repoRoot, dir).split(path6.sep).join("/")).join(", ");
1038
+ const installed = [...new Set(drifted.map((entry) => entry.installed ?? "unknown"))].join(", ");
1039
+ return `roadmap-progress skill is outdated in ${dirs} (installed ${installed}, CLI ${cliVersion}). Run \`nolto init\` in this repo to update it.`;
1040
+ }
613
1041
  async function installSkill(args) {
614
- const targetDir = path3.join(args.skillsParentDir, "roadmap-progress");
615
- const markerPath = path3.join(targetDir, VERSION_MARKER);
1042
+ const targetDir = path6.join(args.skillsParentDir, "roadmap-progress");
1043
+ const markerPath = path6.join(targetDir, VERSION_MARKER);
616
1044
  const dirExists = existsSync(targetDir);
617
1045
  let installedVersion = null;
618
1046
  if (dirExists) {
619
1047
  try {
620
- installedVersion = (await readFile2(markerPath, "utf8")).trim();
1048
+ installedVersion = (await readFile4(markerPath, "utf8")).trim();
621
1049
  } catch {
622
1050
  installedVersion = null;
623
1051
  }
624
1052
  }
625
- if (dirExists && installedVersion === args.version && args.force !== true) {
626
- return { action: "skipped", targetDir };
627
- }
628
- await rm(targetDir, { recursive: true, force: true });
629
- await mkdir2(args.skillsParentDir, { recursive: true });
630
- await cp(args.sourceDir, targetDir, { recursive: true });
631
- await writeFile2(markerPath, args.version + "\n", "utf8");
632
- return { action: dirExists ? "updated" : "installed", targetDir };
633
- }
634
-
635
- // src/registry.ts
636
- import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
637
- import path4 from "path";
638
- import { z as z2 } from "zod";
639
- var registrySchema = z2.object({
640
- schemaVersion: z2.literal(1),
641
- repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
642
- }).passthrough();
643
- function getRegistryPath(env) {
644
- return path4.join(getConfigDir(env), "registry.json");
645
- }
646
- async function loadRegistry(filePath) {
647
- let raw;
648
- try {
649
- raw = await readFile3(filePath, "utf8");
650
- } catch (err) {
651
- const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
652
- if (code === "ENOENT") {
653
- return { schemaVersion: 1, repos: [] };
654
- }
655
- throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
656
- }
657
- let parsed;
658
- try {
659
- parsed = JSON.parse(raw);
660
- } catch {
661
- throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
662
- }
663
- const result = registrySchema.safeParse(parsed);
664
- if (!result.success) {
665
- const issue = result.error.issues[0];
666
- const fieldPath = issue?.path.join(".") ?? "";
667
- throw new CliError(
668
- `Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
669
- 2
670
- );
671
- }
672
- return result.data;
673
- }
674
- async function addRepoToRegistry(filePath, repoRoot) {
675
- const registry = await loadRegistry(filePath);
676
- if (registry.repos.some((repo) => repo.root === repoRoot)) {
677
- return { added: false, registry };
678
- }
679
- const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
680
- await mkdir3(path4.dirname(filePath), { recursive: true, mode: 448 });
681
- await writeFile3(filePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
682
- return { added: true, registry: updated };
1053
+ if (dirExists && installedVersion === args.version && args.force !== true) {
1054
+ return { action: "skipped", targetDir };
1055
+ }
1056
+ await rm(targetDir, { recursive: true, force: true });
1057
+ await mkdir4(args.skillsParentDir, { recursive: true });
1058
+ await cp(args.sourceDir, targetDir, { recursive: true });
1059
+ await writeFile4(markerPath, args.version + "\n", "utf8");
1060
+ return { action: dirExists ? "updated" : "installed", targetDir };
683
1061
  }
684
1062
 
685
1063
  // src/roadmap-scaffold.ts
686
- import { mkdir as mkdir4, readdir, writeFile as writeFile4 } from "fs/promises";
1064
+ import { mkdir as mkdir5, readdir, writeFile as writeFile5 } from "fs/promises";
687
1065
  import { existsSync as existsSync2 } from "fs";
688
- import path5 from "path";
1066
+ import path7 from "path";
689
1067
  function slugifyProjectId(name) {
690
1068
  const slug = name.toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-_.]+$/, "");
691
1069
  return slug.length > 0 ? slug : "project";
692
1070
  }
693
1071
  async function scaffoldRoadmap(args) {
694
- const repoBasename = path5.basename(args.repoRoot);
695
- const dir = path5.join(args.repoRoot, ".nolto", "roadmaps");
696
- const filePath = path5.join(dir, `${slugifyProjectId(repoBasename)}.json`);
697
- const legacyPath = path5.join(args.repoRoot, ".roadmap", "roadmap.json");
1072
+ const repoBasename = path7.basename(args.repoRoot);
1073
+ const dir = path7.join(args.repoRoot, ".nolto", "roadmaps");
1074
+ const filePath = path7.join(dir, `${slugifyProjectId(repoBasename)}.json`);
1075
+ const legacyPath = path7.join(args.repoRoot, ".roadmap", "roadmap.json");
698
1076
  try {
699
1077
  const existing = (await readdir(dir)).sort();
700
1078
  if (existing.length > 0) {
701
- return { created: false, path: path5.join(dir, existing[0]) };
1079
+ return { created: false, path: path7.join(dir, existing[0]) };
702
1080
  }
703
1081
  } catch (err) {
704
1082
  if (err == null || typeof err !== "object" || !("code" in err) || err.code !== "ENOENT") {
@@ -720,19 +1098,19 @@ async function scaffoldRoadmap(args) {
720
1098
  summary: "",
721
1099
  phases: []
722
1100
  };
723
- await mkdir4(dir, { recursive: true });
724
- await writeFile4(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
1101
+ await mkdir5(dir, { recursive: true });
1102
+ await writeFile5(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
725
1103
  return { created: true, path: filePath };
726
1104
  }
727
1105
 
728
1106
  // src/commands/init.ts
729
- var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
1107
+ var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
730
1108
  var _require = createRequire(import.meta.url);
731
1109
  function getCliVersion() {
732
1110
  const candidates = [
733
- path6.resolve(__dirname2, "../package.json"),
1111
+ path8.resolve(__dirname2, "../package.json"),
734
1112
  // bundled: dist/../package.json
735
- path6.resolve(__dirname2, "../../package.json")
1113
+ path8.resolve(__dirname2, "../../package.json")
736
1114
  // source: src/commands/../../package.json
737
1115
  ];
738
1116
  for (const pkgPath of candidates) {
@@ -746,160 +1124,212 @@ function getCliVersion() {
746
1124
  }
747
1125
  return "0.0.0";
748
1126
  }
1127
+ async function pickProject(rl, http, projects, promptText) {
1128
+ if (projects.length > 0) {
1129
+ process.stdout.write("\nProjects:\n");
1130
+ projects.forEach((project, index) => {
1131
+ process.stdout.write(` (${index + 1}) ${project.name} \u2014 ${project.id}
1132
+ `);
1133
+ });
1134
+ } else {
1135
+ process.stdout.write("\nNo projects yet.\n");
1136
+ }
1137
+ const pick = await rl.question(promptText);
1138
+ const trimmedPick = pick.trim().toLowerCase();
1139
+ if (trimmedPick === "c") {
1140
+ const name = await rl.question("New project name: ");
1141
+ if (name.trim().length === 0) {
1142
+ throw new CliError("Project name is required.", 2);
1143
+ }
1144
+ const created = await http.post(
1145
+ "/api/projects",
1146
+ { name: name.trim() }
1147
+ );
1148
+ if (created.project?.id == null) {
1149
+ throw new CliError("Project API did not return a project id.", 2);
1150
+ }
1151
+ const project = {
1152
+ id: created.project.id,
1153
+ name: created.project.name ?? name.trim()
1154
+ };
1155
+ process.stdout.write(`Created project ${project.name} (${project.id})
1156
+ `);
1157
+ return project;
1158
+ }
1159
+ const num = parseInt(trimmedPick, 10);
1160
+ if (!isNaN(num) && num >= 1 && num <= projects.length) {
1161
+ return projects[num - 1];
1162
+ }
1163
+ return void 0;
1164
+ }
749
1165
  function register2(program, deps) {
750
1166
  program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
751
1167
  const configPath = deps.configPath;
752
- if (!opts.force) {
753
- let existing = null;
754
- try {
755
- existing = await loadConfigFile(configPath);
756
- } catch {
757
- }
758
- if (existing != null) {
759
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
760
- try {
761
- const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
762
- if (answer.trim().toLowerCase() !== "y") {
763
- process.stdout.write("Cancelled.\n");
764
- return;
765
- }
766
- } finally {
767
- rl2.close();
768
- }
769
- }
770
- }
771
1168
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
772
- let token = "";
773
1169
  try {
774
- const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
775
- const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
776
- const currentToken = deps.settings.token;
777
- const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
778
- const enteredToken = await promptHidden(rl, tokenPrompt);
779
- token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
780
- if (token.length === 0) {
781
- throw new CliError("Token is required.", 2);
782
- }
783
- const http = createHttpClient({
784
- baseUrl,
785
- token,
786
- version: getCliVersion()
787
- });
788
- let projects = [];
789
- try {
790
- const result = await http.get("/api/projects");
791
- projects = Array.isArray(result.projects) ? result.projects : [];
792
- } catch (err) {
793
- if (err instanceof CliError && err.exitCode === 3) {
794
- throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
1170
+ let configureGlobal = opts.force === true;
1171
+ if (!configureGlobal) {
1172
+ let existing = null;
1173
+ try {
1174
+ existing = await loadConfigFile(configPath);
1175
+ } catch {
1176
+ }
1177
+ if (existing == null || deps.settings.token == null) {
1178
+ configureGlobal = true;
1179
+ } else {
1180
+ const answer = await rl.question(
1181
+ `Config already exists at ${configPath}. Reconfigure token and base URL? [y/N] `
1182
+ );
1183
+ configureGlobal = answer.trim().toLowerCase() === "y";
795
1184
  }
796
- throw err;
797
1185
  }
798
- let defaultProjectId;
1186
+ let token = deps.settings.token ?? "";
1187
+ let baseUrl = deps.settings.baseUrl;
1188
+ let defaultProjectId = deps.settings.defaultProjectId;
799
1189
  let defaultProjectName;
800
- if (projects.length > 0) {
801
- process.stdout.write("\nProjects:\n");
802
- projects.forEach((p, i) => {
803
- process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
804
- `);
1190
+ let http;
1191
+ let projects;
1192
+ if (configureGlobal) {
1193
+ const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
1194
+ baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
1195
+ const currentToken = deps.settings.token;
1196
+ const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
1197
+ const enteredToken = await promptHidden(rl, tokenPrompt);
1198
+ token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
1199
+ if (token.length === 0) {
1200
+ throw new CliError("Token is required.", 2);
1201
+ }
1202
+ http = createHttpClient({
1203
+ baseUrl,
1204
+ token,
1205
+ version: getCliVersion()
805
1206
  });
806
- } else {
807
- process.stdout.write("\nNo projects yet.\n");
808
- }
809
- const pick = await rl.question("Default project number, 'c' to create new (or Enter to skip): ");
810
- const trimmedPick = pick.trim().toLowerCase();
811
- if (trimmedPick === "c") {
812
- const name = await rl.question("New project name: ");
813
- if (name.trim().length === 0) {
814
- throw new CliError("Project name is required.", 2);
1207
+ try {
1208
+ const result = await http.get("/api/projects");
1209
+ projects = Array.isArray(result.projects) ? result.projects : [];
1210
+ } catch (err) {
1211
+ if (err instanceof CliError && err.exitCode === 3) {
1212
+ throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
1213
+ }
1214
+ throw err;
815
1215
  }
816
- const created = await http.post(
817
- "/api/projects",
818
- { name: name.trim() }
1216
+ const selected = await pickProject(
1217
+ rl,
1218
+ http,
1219
+ projects,
1220
+ "Default project number, 'c' to create new (or Enter to skip): "
819
1221
  );
820
- if (created.project?.id == null) {
821
- throw new CliError("Project API did not return a project id.", 2);
822
- }
823
- defaultProjectId = created.project.id;
824
- defaultProjectName = created.project.name ?? name.trim();
825
- process.stdout.write(`Created project ${defaultProjectName} (${defaultProjectId})
826
- `);
827
- } else {
828
- const num = parseInt(trimmedPick, 10);
829
- if (!isNaN(num) && num >= 1 && num <= projects.length) {
830
- defaultProjectId = projects[num - 1].id;
831
- defaultProjectName = projects[num - 1].name;
832
- }
833
- }
834
- await saveConfigFile(configPath, {
835
- token,
836
- baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
837
- defaultProjectId
838
- });
839
- const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
840
- process.stdout.write(`
1222
+ defaultProjectId = selected?.id;
1223
+ defaultProjectName = selected?.name;
1224
+ await saveConfigFile(configPath, {
1225
+ token,
1226
+ baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
1227
+ defaultProjectId
1228
+ });
1229
+ const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
1230
+ process.stdout.write(`
841
1231
  Saved ${configPath}
842
1232
  `);
843
- process.stdout.write(`baseUrl: ${baseUrl}
1233
+ process.stdout.write(`baseUrl: ${baseUrl}
844
1234
  `);
845
- process.stdout.write(`token: ${maskToken(token)} (verified)
1235
+ process.stdout.write(`token: ${maskToken(token)} (verified)
846
1236
  `);
847
- process.stdout.write(`defaultProject: ${projectDisplay}
1237
+ process.stdout.write(`defaultProject: ${projectDisplay}
848
1238
  `);
849
- if (defaultProjectId != null) {
850
- const startDir = resolveStartDir(process.env, process.cwd());
851
- const { root, foundGit } = findRepoRoot(startDir);
852
- if (foundGit) {
853
- const setup = await rl.question(`
1239
+ } else {
1240
+ const projectDisplay = defaultProjectId ?? "not set";
1241
+ process.stdout.write(
1242
+ `Using existing config: baseUrl=${baseUrl}, token=${maskToken(token)}, defaultProject=${projectDisplay}
1243
+ `
1244
+ );
1245
+ }
1246
+ const startDir = resolveStartDir(process.env, process.cwd());
1247
+ const { root, foundGit } = findRepoRoot(startDir);
1248
+ if (!foundGit) {
1249
+ process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
1250
+ return;
1251
+ }
1252
+ if (isHomeDirectory(root)) {
1253
+ process.stderr.write(
1254
+ `Refusing to set up your home directory as a repository root (found ${path8.join(os3.homedir(), ".git")}). Run nolto init inside a project repository.
1255
+ `
1256
+ );
1257
+ return;
1258
+ }
1259
+ const existingBinding = deps.repoBinding?.error == null ? deps.repoBinding?.binding ?? null : null;
1260
+ let repoProject = existingBinding != null ? { id: existingBinding.projectId, name: path8.basename(root) } : defaultProjectId != null ? { id: defaultProjectId, name: defaultProjectName ?? path8.basename(root) } : void 0;
1261
+ if (repoProject == null) {
1262
+ http ??= createHttpClient({ baseUrl, token, version: getCliVersion() });
1263
+ if (projects == null) {
1264
+ const result = await http.get("/api/projects");
1265
+ projects = Array.isArray(result.projects) ? result.projects : [];
1266
+ }
1267
+ repoProject = await pickProject(
1268
+ rl,
1269
+ http,
1270
+ projects,
1271
+ "Project number for this repository, 'c' to create new (or Enter to skip): "
1272
+ );
1273
+ }
1274
+ if (repoProject == null) {
1275
+ process.stdout.write("Skipped repo setup (no project selected).\n");
1276
+ return;
1277
+ }
1278
+ const setup = await rl.question(`
854
1279
  Set up this repository (${root}) for roadmap sync? [Y/n] `);
855
- if (setup.trim().toLowerCase() !== "n") {
856
- if (deps.repoBinding?.error != null) {
857
- process.stderr.write(
858
- `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
1280
+ if (setup.trim().toLowerCase() === "n") {
1281
+ process.stdout.write("Skipped repo setup.\n");
1282
+ return;
1283
+ }
1284
+ const bindingPath = deps.repoBinding?.path ?? path8.join(root, "nolto.json");
1285
+ if (existingBinding != null) {
1286
+ process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
1287
+ `);
1288
+ } else {
1289
+ if (deps.repoBinding?.error != null) {
1290
+ process.stderr.write(
1291
+ `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
859
1292
  `
860
- );
861
- }
862
- await writeRepoBinding(root, defaultProjectId);
863
- process.stdout.write(`binding: wrote ${path6.join(root, "nolto.json")}
1293
+ );
1294
+ }
1295
+ await writeRepoBinding(root, repoProject.id);
1296
+ process.stdout.write(`binding: wrote ${path8.join(root, "nolto.json")}
864
1297
  `);
865
- const sourceDir = resolveSkillSourceDir();
866
- const version = getCliVersion();
867
- const claudeInstall = await installSkill({
868
- skillsParentDir: path6.join(root, ".claude", "skills"),
869
- sourceDir,
870
- version
871
- });
872
- process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
1298
+ }
1299
+ const sourceDir = resolveSkillSourceDir();
1300
+ const version = getCliVersion();
1301
+ const claudeInstall = await installSkill({
1302
+ skillsParentDir: path8.join(root, ".claude", "skills"),
1303
+ sourceDir,
1304
+ version
1305
+ });
1306
+ process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
873
1307
  `);
874
- if (fs.existsSync(path6.join(root, ".agents")) || fs.existsSync(path6.join(root, ".codex"))) {
875
- const agentsInstall = await installSkill({
876
- skillsParentDir: path6.join(root, ".agents", "skills"),
877
- sourceDir,
878
- version
879
- });
880
- process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
1308
+ const usesAgentsTooling = fs.existsSync(path8.join(root, ".agents")) || fs.existsSync(path8.join(root, ".codex")) || fs.existsSync(path8.join(root, "AGENTS.md"));
1309
+ if (usesAgentsTooling) {
1310
+ const agentsInstall = await installSkill({
1311
+ skillsParentDir: path8.join(root, ".agents", "skills"),
1312
+ sourceDir,
1313
+ version
1314
+ });
1315
+ process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
881
1316
  `);
882
- }
883
- const scaffold = await scaffoldRoadmap({
884
- repoRoot: root,
885
- projectName: defaultProjectName ?? path6.basename(root)
886
- });
887
- process.stdout.write(
888
- scaffold.created ? `roadmap: created ${scaffold.path}
1317
+ }
1318
+ const scaffold = await scaffoldRoadmap({
1319
+ repoRoot: root,
1320
+ projectName: repoProject.name
1321
+ });
1322
+ process.stdout.write(
1323
+ scaffold.created ? `roadmap: created ${scaffold.path}
889
1324
  ` : `roadmap: exists ${scaffold.path}
890
1325
  `
891
- );
892
- const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
893
- process.stdout.write(
894
- registryResult.added ? `watch registry: added ${root}
1326
+ );
1327
+ const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
1328
+ process.stdout.write(
1329
+ registryResult.added ? `watch registry: added ${root}
895
1330
  ` : `watch registry: already registered
896
1331
  `
897
- );
898
- }
899
- } else {
900
- process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
901
- }
902
- }
1332
+ );
903
1333
  } finally {
904
1334
  rl.close();
905
1335
  }
@@ -1119,142 +1549,15 @@ function register4(program, deps) {
1119
1549
  }
1120
1550
 
1121
1551
  // src/commands/sync.ts
1122
- import { copyFile, mkdir as mkdir5, readFile as readFile4, readdir as readdir2, rename, rmdir, unlink } from "fs/promises";
1552
+ import { copyFile, mkdir as mkdir6, readFile as readFile5, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1123
1553
  import { existsSync as existsSync3 } from "fs";
1124
1554
 
1125
1555
  // src/sync-repo.ts
1126
- import path8 from "path";
1556
+ import path10 from "path";
1127
1557
 
1128
1558
  // src/sync-core.ts
1129
1559
  import { createHash } from "crypto";
1130
- import path7 from "path";
1131
-
1132
- // ../roadmap-schema/src/index.ts
1133
- var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
1134
- var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
1135
- var ALLOWED_KEYS = {
1136
- roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
1137
- project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
1138
- phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
1139
- task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
1140
- };
1141
- function isRecord(value) {
1142
- return value != null && typeof value === "object" && !Array.isArray(value);
1143
- }
1144
- function checkKeys(value, allowed, at, errors) {
1145
- if (!isRecord(value)) return;
1146
- for (const key of Object.keys(value)) {
1147
- if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
1148
- }
1149
- }
1150
- function checkPlan(value, at, errors) {
1151
- if (value === void 0) return;
1152
- if (typeof value !== "string" || value.length === 0) {
1153
- errors.push(`${at}.plan must be a non-empty string.`);
1154
- }
1155
- }
1156
- function derivePhaseStatus(phase) {
1157
- if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
1158
- if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
1159
- if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
1160
- if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
1161
- return "todo";
1162
- }
1163
- function validateRoadmap(value) {
1164
- const errors = [];
1165
- const warnings = [];
1166
- if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
1167
- checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
1168
- if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
1169
- errors.push("schemaVersion must be 1 or 2.");
1170
- } else if (value["schemaVersion"] === 1) {
1171
- warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
1172
- }
1173
- const project = value["project"];
1174
- checkKeys(project, ALLOWED_KEYS.project, "project", errors);
1175
- const projectRecord = isRecord(project) ? project : {};
1176
- if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
1177
- if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
1178
- if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
1179
- if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
1180
- if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
1181
- if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
1182
- const allIds = /* @__PURE__ */ new Set();
1183
- const tasks = /* @__PURE__ */ new Map();
1184
- const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
1185
- for (const [phaseIndex, rawPhase] of phases.entries()) {
1186
- const at = `phases[${phaseIndex}]`;
1187
- checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
1188
- const phase = isRecord(rawPhase) ? rawPhase : {};
1189
- const phaseId = String(phase["id"] ?? "");
1190
- if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
1191
- if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
1192
- allIds.add(phaseId);
1193
- if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
1194
- if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
1195
- checkPlan(phase["plan"], at, errors);
1196
- if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
1197
- const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
1198
- for (const [taskIndex, rawTask] of rawTasks.entries()) {
1199
- const taskAt = `${at}.tasks[${taskIndex}]`;
1200
- checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
1201
- const task = isRecord(rawTask) ? rawTask : {};
1202
- const taskId = String(task["id"] ?? "");
1203
- if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
1204
- if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
1205
- allIds.add(taskId);
1206
- tasks.set(taskId, task);
1207
- if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
1208
- if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
1209
- checkPlan(task["plan"], taskAt, errors);
1210
- if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
1211
- if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
1212
- if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
1213
- }
1214
- if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
1215
- const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
1216
- if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
1217
- }
1218
- }
1219
- for (const task of tasks.values()) {
1220
- for (const dependency of task.dependsOn ?? []) {
1221
- if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
1222
- if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
1223
- }
1224
- }
1225
- const currentTaskId = value["currentTaskId"];
1226
- if (currentTaskId !== null && currentTaskId !== void 0) {
1227
- const current = tasks.get(String(currentTaskId));
1228
- if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
1229
- else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
1230
- }
1231
- return { errors, warnings };
1232
- }
1233
- function deriveTaskStats(roadmap) {
1234
- let done = 0;
1235
- let blocked = 0;
1236
- let inProgress = 0;
1237
- let todo = 0;
1238
- for (const phase of roadmap.phases) {
1239
- for (const task of phase.tasks) {
1240
- if (task.status === "done") done += 1;
1241
- else if (task.status === "blocked") blocked += 1;
1242
- else if (task.status === "in-progress") inProgress += 1;
1243
- else todo += 1;
1244
- }
1245
- }
1246
- const total = done + blocked + inProgress + todo;
1247
- return {
1248
- total,
1249
- done,
1250
- blocked,
1251
- inProgress,
1252
- todo,
1253
- progressPct: total > 0 ? Math.round(done / total * 100) : 0
1254
- };
1255
- }
1256
-
1257
- // src/sync-core.ts
1560
+ import path9 from "path";
1258
1561
  function sha256Hex(content) {
1259
1562
  return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1260
1563
  }
@@ -1275,10 +1578,10 @@ function collectPlanRefs(roadmap) {
1275
1578
  }
1276
1579
  return refs;
1277
1580
  }
1278
- async function loadValidRoadmap(filePath, readFile7) {
1581
+ async function loadValidRoadmap(filePath, readFile8) {
1279
1582
  let raw;
1280
1583
  try {
1281
- raw = await readFile7(filePath);
1584
+ raw = await readFile8(filePath);
1282
1585
  } catch {
1283
1586
  throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1284
1587
  }
@@ -1301,7 +1604,7 @@ async function loadValidRoadmap(filePath, readFile7) {
1301
1604
  async function buildSyncBody(args) {
1302
1605
  const planDocuments = [];
1303
1606
  for (const ref of collectPlanRefs(args.roadmap)) {
1304
- const absolute = path7.join(args.repoRoot, ref.path);
1607
+ const absolute = path9.join(args.repoRoot, ref.path);
1305
1608
  if (!args.deps.fileExists(absolute)) {
1306
1609
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1307
1610
  continue;
@@ -1314,12 +1617,13 @@ async function buildSyncBody(args) {
1314
1617
  contentHash: sha256Hex(content)
1315
1618
  });
1316
1619
  }
1317
- return { roadmap: args.roadmap, planDocuments };
1620
+ return { roadmap: args.roadmap, planDocuments, repoIdentity: args.repoIdentity };
1318
1621
  }
1319
1622
  async function runSync(args, deps) {
1320
1623
  const body = await buildSyncBody({
1321
1624
  roadmap: args.roadmap,
1322
1625
  repoRoot: args.repoRoot,
1626
+ repoIdentity: args.repoIdentity,
1323
1627
  deps
1324
1628
  });
1325
1629
  const response = await deps.http.put(
@@ -1347,7 +1651,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
1347
1651
  }
1348
1652
  }
1349
1653
  async function migrateLegacyRoadmap(args) {
1350
- const targetPath = path8.join(args.roadmapsDir, `${args.slug}.json`);
1654
+ const targetPath = path10.join(args.roadmapsDir, `${args.slug}.json`);
1351
1655
  await args.io.mkdir(args.roadmapsDir);
1352
1656
  try {
1353
1657
  await args.io.rename(args.legacyPath, targetPath);
@@ -1356,7 +1660,7 @@ async function migrateLegacyRoadmap(args) {
1356
1660
  await args.io.unlink(args.legacyPath);
1357
1661
  }
1358
1662
  try {
1359
- await args.io.rmdir(path8.dirname(args.legacyPath));
1663
+ await args.io.rmdir(path10.dirname(args.legacyPath));
1360
1664
  } catch {
1361
1665
  }
1362
1666
  args.io.log(
@@ -1365,14 +1669,14 @@ async function migrateLegacyRoadmap(args) {
1365
1669
  return `${args.slug}.json`;
1366
1670
  }
1367
1671
  async function syncRepo(args, io) {
1368
- const bindingPath = path8.join(args.root, "nolto.json");
1672
+ const bindingPath = path10.join(args.root, "nolto.json");
1369
1673
  const binding = await loadRepoBinding(bindingPath);
1370
1674
  const projectId = binding?.projectId ?? args.defaultProjectId;
1371
1675
  if (projectId == null) {
1372
1676
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1373
1677
  }
1374
- const roadmapsDir = path8.join(args.root, ".nolto", "roadmaps");
1375
- const legacyPath = path8.join(args.root, ".roadmap", "roadmap.json");
1678
+ const roadmapsDir = path10.join(args.root, ".nolto", "roadmaps");
1679
+ const legacyPath = path10.join(args.root, ".roadmap", "roadmap.json");
1376
1680
  let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1377
1681
  if (roadmapFiles.length > 0) {
1378
1682
  if (io.fileExists(legacyPath)) {
@@ -1381,7 +1685,13 @@ async function syncRepo(args, io) {
1381
1685
  );
1382
1686
  }
1383
1687
  } else if (io.fileExists(legacyPath)) {
1384
- const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path8.basename(args.root));
1688
+ if (args.migrateLegacy !== true) {
1689
+ io.warn(
1690
+ "legacy .roadmap/roadmap.json found \u2014 run `nolto sync` once to migrate it to .nolto/roadmaps/ (watch does not migrate automatically)"
1691
+ );
1692
+ return { results: [], planAbsPaths: [] };
1693
+ }
1694
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path10.basename(args.root));
1385
1695
  roadmapFiles = [
1386
1696
  await migrateLegacyRoadmap({
1387
1697
  slug: migrationSlug,
@@ -1405,21 +1715,22 @@ async function syncRepo(args, io) {
1405
1715
  2
1406
1716
  );
1407
1717
  }
1408
- const filePath = path8.join(roadmapsDir, fileName);
1718
+ const filePath = path10.join(roadmapsDir, fileName);
1409
1719
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1410
1720
  })
1411
1721
  );
1412
1722
  const planAbsPaths = /* @__PURE__ */ new Set();
1413
1723
  for (const { roadmap } of roadmaps) {
1414
1724
  for (const ref of collectPlanRefs(roadmap)) {
1415
- planAbsPaths.add(path8.join(args.root, ref.path));
1725
+ planAbsPaths.add(path10.join(args.root, ref.path));
1416
1726
  }
1417
1727
  }
1418
1728
  const results = [];
1729
+ const repoIdentity = await io.repoIdentity(args.root);
1419
1730
  for (const { slug, roadmap } of roadmaps) {
1420
1731
  results.push(
1421
1732
  await runSync(
1422
- { repoRoot: args.root, projectId, slug, roadmap },
1733
+ { repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
1423
1734
  { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1424
1735
  )
1425
1736
  );
@@ -1438,38 +1749,61 @@ function register5(program, deps) {
1438
1749
  if (!foundGit) {
1439
1750
  throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1440
1751
  }
1441
- const http = createHttpClient({
1442
- baseUrl: deps.settings.baseUrl,
1443
- version: deps.version,
1444
- token: deps.settings.token
1445
- });
1752
+ const http = deps.http;
1753
+ const warn = (line) => {
1754
+ process.stderr.write("Warning: " + line + "\n");
1755
+ };
1446
1756
  const response = await syncRepo(
1447
- { root, defaultProjectId: deps.settings.defaultProjectId },
1757
+ { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
1448
1758
  {
1449
- readFile: (p) => readFile4(p, "utf8"),
1759
+ readFile: (p) => readFile5(p, "utf8"),
1450
1760
  fileExists: (p) => existsSync3(p),
1451
1761
  listDir: (p) => readdir2(p),
1452
- rename,
1762
+ rename: rename2,
1453
1763
  copyFile,
1454
- mkdir: (p) => mkdir5(p, { recursive: true }).then(() => void 0),
1455
- unlink,
1764
+ mkdir: (p) => mkdir6(p, { recursive: true }).then(() => void 0),
1765
+ unlink: unlink2,
1456
1766
  rmdir,
1767
+ repoIdentity: makeRepoIdentityResolver(deps),
1457
1768
  http,
1458
1769
  log: (line) => process.stdout.write(line + "\n"),
1459
- warn: (line) => process.stderr.write("Warning: " + line + "\n")
1770
+ warn
1460
1771
  }
1461
1772
  );
1773
+ try {
1774
+ const drifted = await checkSkillVersionDrift(root, deps.version);
1775
+ if (drifted.length > 0) {
1776
+ warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
1777
+ }
1778
+ } catch {
1779
+ }
1780
+ let registryAdded = false;
1781
+ if (isHomeDirectory(root)) {
1782
+ process.stderr.write(`Warning: refusing to add home directory ${root} to the watch registry.
1783
+ `);
1784
+ } else {
1785
+ try {
1786
+ registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
1787
+ } catch (err) {
1788
+ const message = err instanceof Error ? err.message : String(err);
1789
+ process.stderr.write(`Warning: could not update watch registry: ${message}
1790
+ `);
1791
+ }
1792
+ }
1462
1793
  if (deps.output.mode === "json") {
1463
1794
  const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
1464
- printResult(publicResponse, "json");
1795
+ printResult({ ...publicResponse, registryAdded }, "json");
1796
+ } else if (registryAdded) {
1797
+ process.stdout.write(`watch registry: added ${root}
1798
+ `);
1465
1799
  }
1466
1800
  });
1467
1801
  }
1468
1802
 
1469
1803
  // src/commands/watch.ts
1470
- import { copyFile as copyFile2, mkdir as mkdir6, readFile as readFile5, readdir as readdir3, rename as rename2, rmdir as rmdir2, unlink as unlink2 } from "fs/promises";
1804
+ import { copyFile as copyFile2, mkdir as mkdir7, readFile as readFile6, readdir as readdir3, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
1471
1805
  import { existsSync as existsSync4 } from "fs";
1472
- import path10 from "path";
1806
+ import path12 from "path";
1473
1807
  import chokidar from "chokidar";
1474
1808
 
1475
1809
  // src/watch-core.ts
@@ -1575,8 +1909,8 @@ var RepoWatch = class {
1575
1909
  };
1576
1910
 
1577
1911
  // src/service-install.ts
1578
- import path9 from "path";
1579
- import os2 from "os";
1912
+ import path11 from "path";
1913
+ import os4 from "os";
1580
1914
  function buildUnitFile(args) {
1581
1915
  return [
1582
1916
  "[Unit]",
@@ -1595,8 +1929,8 @@ function buildUnitFile(args) {
1595
1929
  }
1596
1930
  function getUnitPath(env) {
1597
1931
  const xdg = env["XDG_CONFIG_HOME"];
1598
- const base = xdg != null && xdg.length > 0 ? xdg : path9.join(os2.homedir(), ".config");
1599
- return path9.join(base, "systemd", "user", "nolto-watch.service");
1932
+ const base = xdg != null && xdg.length > 0 ? xdg : path11.join(os4.homedir(), ".config");
1933
+ return path11.join(base, "systemd", "user", "nolto-watch.service");
1600
1934
  }
1601
1935
  async function installServiceWith(deps) {
1602
1936
  if (deps.platform !== "linux") {
@@ -1607,7 +1941,7 @@ async function installServiceWith(deps) {
1607
1941
  );
1608
1942
  }
1609
1943
  const unitPath = getUnitPath(deps.env);
1610
- await deps.mkdir(path9.dirname(unitPath));
1944
+ await deps.mkdir(path11.dirname(unitPath));
1611
1945
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1612
1946
  deps.log(`Wrote ${unitPath}`);
1613
1947
  const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
@@ -1621,18 +1955,18 @@ async function installServiceWith(deps) {
1621
1955
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1622
1956
  }
1623
1957
  async function installService() {
1624
- const { writeFile: writeFile6, mkdir: mkdir8 } = await import("fs/promises");
1625
- const { execFile: execFile2 } = await import("child_process");
1958
+ const { writeFile: writeFile7, mkdir: mkdir9 } = await import("fs/promises");
1959
+ const { execFile: execFile3 } = await import("child_process");
1626
1960
  const { promisify: promisify2 } = await import("util");
1627
- const execFileAsync = promisify2(execFile2);
1961
+ const execFileAsync = promisify2(execFile3);
1628
1962
  await installServiceWith({
1629
1963
  platform: process.platform,
1630
1964
  env: process.env,
1631
1965
  nodePath: process.execPath,
1632
- scriptPath: path9.resolve(process.argv[1] ?? ""),
1633
- writeFile: (p, content) => writeFile6(p, content, "utf8"),
1966
+ scriptPath: path11.resolve(process.argv[1] ?? ""),
1967
+ writeFile: (p, content) => writeFile7(p, content, "utf8"),
1634
1968
  mkdir: async (p) => {
1635
- await mkdir8(p, { recursive: true });
1969
+ await mkdir9(p, { recursive: true });
1636
1970
  },
1637
1971
  exec: async (cmd) => {
1638
1972
  try {
@@ -1686,14 +2020,14 @@ async function uninstallServiceWith(deps) {
1686
2020
  deps.log("Service nolto-watch stopped, disabled, and removed.");
1687
2021
  }
1688
2022
  async function uninstallService() {
1689
- const { unlink: unlink3 } = await import("fs/promises");
1690
- const { execFile: execFile2 } = await import("child_process");
2023
+ const { unlink: unlink4 } = await import("fs/promises");
2024
+ const { execFile: execFile3 } = await import("child_process");
1691
2025
  const { promisify: promisify2 } = await import("util");
1692
- const execFileAsync = promisify2(execFile2);
2026
+ const execFileAsync = promisify2(execFile3);
1693
2027
  await uninstallServiceWith({
1694
2028
  platform: process.platform,
1695
2029
  env: process.env,
1696
- unlink: unlink3,
2030
+ unlink: unlink4,
1697
2031
  exec: async (cmd) => {
1698
2032
  try {
1699
2033
  await execFileAsync(cmd[0], cmd.slice(1));
@@ -1746,26 +2080,31 @@ function register6(program, deps) {
1746
2080
  token: deps.settings.token
1747
2081
  });
1748
2082
  const startRepo = (root) => {
1749
- const roadmapsPath = path10.join(root, ".nolto", "roadmaps");
1750
- const legacyRoadmapPath = path10.join(root, ".roadmap", "roadmap.json");
2083
+ const roadmapsPath = path12.join(root, ".nolto", "roadmaps");
2084
+ const legacyRoadmapPath = path12.join(root, ".roadmap", "roadmap.json");
2085
+ const warn = (line) => {
2086
+ process.stderr.write(`Warning: [${path12.basename(root)}] ${line}
2087
+ `);
2088
+ };
1751
2089
  const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
1752
2090
  const repoWatch = new RepoWatch(root, {
1753
2091
  sync: () => syncRepo(
1754
- { root, defaultProjectId: deps.settings.defaultProjectId },
2092
+ // #316: watch must warn about legacy roadmaps without migrating them.
2093
+ { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
1755
2094
  {
1756
- readFile: (p) => readFile5(p, "utf8"),
2095
+ readFile: (p) => readFile6(p, "utf8"),
1757
2096
  fileExists: (p) => existsSync4(p),
1758
2097
  listDir: (p) => readdir3(p),
1759
- rename: rename2,
2098
+ rename: rename3,
1760
2099
  copyFile: copyFile2,
1761
- mkdir: (p) => mkdir6(p, { recursive: true }).then(() => void 0),
1762
- unlink: unlink2,
2100
+ mkdir: (p) => mkdir7(p, { recursive: true }).then(() => void 0),
2101
+ unlink: unlink3,
1763
2102
  rmdir: rmdir2,
2103
+ repoIdentity: makeRepoIdentityResolver(deps),
1764
2104
  http,
1765
- log: (line) => process.stdout.write(`[${path10.basename(root)}] ${line}
2105
+ log: (line) => process.stdout.write(`[${path12.basename(root)}] ${line}
1766
2106
  `),
1767
- warn: (line) => process.stderr.write(`Warning: [${path10.basename(root)}] ${line}
1768
- `)
2107
+ warn
1769
2108
  }
1770
2109
  ),
1771
2110
  watcher,
@@ -1776,6 +2115,12 @@ function register6(program, deps) {
1776
2115
  warn: (line) => process.stderr.write(line + "\n")
1777
2116
  });
1778
2117
  watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
2118
+ void checkSkillVersionDrift(root, deps.version).then((drifted) => {
2119
+ if (drifted.length > 0) {
2120
+ warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
2121
+ }
2122
+ }).catch(() => {
2123
+ });
1779
2124
  void repoWatch.flush();
1780
2125
  return { stop: () => watcher.close() };
1781
2126
  };
@@ -1820,18 +2165,18 @@ function register6(program, deps) {
1820
2165
  }
1821
2166
 
1822
2167
  // src/update-cli.ts
1823
- import { execFile } from "child_process";
2168
+ import { execFile as execFile2 } from "child_process";
1824
2169
  import { existsSync as existsSync5 } from "fs";
1825
2170
  import { realpath } from "fs/promises";
1826
2171
  import { createRequire as createRequire2 } from "module";
1827
- import path12 from "path";
2172
+ import path14 from "path";
1828
2173
  import { fileURLToPath as fileURLToPath3 } from "url";
1829
2174
  import { promisify } from "util";
1830
2175
 
1831
2176
  // src/update-notifier.ts
1832
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir7 } from "fs/promises";
2177
+ import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir8 } from "fs/promises";
1833
2178
  import https from "https";
1834
- import path11 from "path";
2179
+ import path13 from "path";
1835
2180
  var PACKAGE = "@nolto/cli";
1836
2181
  var CACHE_FILE = "update-check.json";
1837
2182
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -1889,9 +2234,9 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
1889
2234
  });
1890
2235
  }
1891
2236
  async function writeUpdateCache(cachePath, now, latest) {
1892
- await mkdir7(path11.dirname(cachePath), { recursive: true });
2237
+ await mkdir8(path13.dirname(cachePath), { recursive: true });
1893
2238
  const payload = { checkedAt: now, latest };
1894
- await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 });
2239
+ await writeFile6(cachePath, JSON.stringify(payload), { mode: 384 });
1895
2240
  }
1896
2241
  async function refreshCache(cachePath, now, fetchLatest) {
1897
2242
  const latest = await fetchLatest();
@@ -1900,10 +2245,10 @@ async function refreshCache(cachePath, now, fetchLatest) {
1900
2245
  }
1901
2246
  async function checkForUpdate(opts) {
1902
2247
  if (isDisabled(opts.env)) return null;
1903
- const cachePath = path11.join(opts.configDir, CACHE_FILE);
2248
+ const cachePath = path13.join(opts.configDir, CACHE_FILE);
1904
2249
  let cache = {};
1905
2250
  try {
1906
- cache = JSON.parse(await readFile6(cachePath, "utf8"));
2251
+ cache = JSON.parse(await readFile7(cachePath, "utf8"));
1907
2252
  } catch {
1908
2253
  }
1909
2254
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2008,7 +2353,7 @@ async function updateCliWith(deps) {
2008
2353
  }
2009
2354
  deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2010
2355
  await deps.writeCache(
2011
- path12.join(deps.configDir, UPDATE_CACHE_FILE),
2356
+ path14.join(deps.configDir, UPDATE_CACHE_FILE),
2012
2357
  deps.now,
2013
2358
  latest
2014
2359
  ).catch(() => void 0);
@@ -2039,17 +2384,17 @@ async function updateCliWith(deps) {
2039
2384
  };
2040
2385
  }
2041
2386
  function getCurrentVersion() {
2042
- const dirname = path12.dirname(fileURLToPath3(import.meta.url));
2387
+ const dirname = path14.dirname(fileURLToPath3(import.meta.url));
2043
2388
  const require3 = createRequire2(import.meta.url);
2044
2389
  try {
2045
- const pkg = require3(path12.resolve(dirname, "../package.json"));
2390
+ const pkg = require3(path14.resolve(dirname, "../package.json"));
2046
2391
  return pkg.version ?? "0.0.0";
2047
2392
  } catch {
2048
2393
  return "0.0.0";
2049
2394
  }
2050
2395
  }
2051
2396
  async function updateCli(opts = {}) {
2052
- const execFileAsync = promisify(execFile);
2397
+ const execFileAsync = promisify(execFile2);
2053
2398
  const scriptPath = await realpath(process.argv[1] ?? "");
2054
2399
  return updateCliWith({
2055
2400
  currentVersion: getCurrentVersion(),
@@ -2117,11 +2462,11 @@ function buildProgram(deps) {
2117
2462
  }
2118
2463
 
2119
2464
  // src/index.ts
2120
- var __dirname3 = path13.dirname(fileURLToPath4(import.meta.url));
2465
+ var __dirname3 = path15.dirname(fileURLToPath4(import.meta.url));
2121
2466
  var require2 = createRequire3(import.meta.url);
2122
2467
  function getVersion() {
2123
2468
  try {
2124
- const pkgPath = path13.resolve(__dirname3, "../package.json");
2469
+ const pkgPath = path15.resolve(__dirname3, "../package.json");
2125
2470
  const pkg = require2(pkgPath);
2126
2471
  return pkg.version ?? "0.0.0";
2127
2472
  } catch {