@nolto/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +39 -2
  2. package/dist/index.js +964 -236
  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 path15 from "path";
6
+ import path19 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -314,11 +314,11 @@ function maskToken(token) {
314
314
  function createHttpClient(opts) {
315
315
  const { baseUrl, version, token } = opts;
316
316
  const base = baseUrl.replace(/\/+$/, "");
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);
317
+ async function request(method, path20, body) {
318
+ if (!path20.startsWith("/api/")) {
319
+ throw new CliError(`HTTP client path must start with /api/, got: ${path20}`, 2);
320
320
  }
321
- const url = `${base}${path16}`;
321
+ const url = `${base}${path20}`;
322
322
  const headers = {
323
323
  "Content-Type": "application/json",
324
324
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -377,10 +377,12 @@ import { Command } from "commander";
377
377
  import readline from "readline/promises";
378
378
  import { createRequire } from "module";
379
379
  import { fileURLToPath as fileURLToPath2 } from "url";
380
- import path8 from "path";
380
+ import os3 from "os";
381
+ import path9 from "path";
381
382
  import fs from "fs";
382
383
 
383
384
  // src/commands/link.ts
385
+ import os2 from "os";
384
386
  import path5 from "path";
385
387
  import { statSync as statSync2 } from "fs";
386
388
 
@@ -517,12 +519,12 @@ function normalizeRemote(raw) {
517
519
  const firstSlash = s.indexOf("/");
518
520
  if (firstSlash <= 0) return null;
519
521
  let host = s.slice(0, firstSlash).toLowerCase();
520
- let path16 = s.slice(firstSlash + 1);
522
+ let path20 = s.slice(firstSlash + 1);
521
523
  if (hadScheme) host = host.replace(/:\d+$/, "");
522
- path16 = path16.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
523
- if (path16.length === 0) return null;
524
- if (HOSTED_LOWERCASE_PATH.has(host)) path16 = path16.toLowerCase();
525
- return `${host}/${path16}`;
524
+ path20 = path20.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
525
+ if (path20.length === 0) return null;
526
+ if (HOSTED_LOWERCASE_PATH.has(host)) path20 = path20.toLowerCase();
527
+ return `${host}/${path20}`;
526
528
  }
527
529
 
528
530
  // ../roadmap-schema/src/index.ts
@@ -771,6 +773,9 @@ function findRepoRoot(startDir, hasGit = dirHasGit) {
771
773
  }
772
774
  return { root: startDir, foundGit: false };
773
775
  }
776
+ function isHomeDirectory(dir, home = os2.homedir()) {
777
+ return path5.resolve(dir) === path5.resolve(home);
778
+ }
774
779
  async function handleShow(deps, projectBindingPath, mode2) {
775
780
  const startDir = resolveStartDir(process.env, process.cwd());
776
781
  const root = findRepoRoot(startDir).root;
@@ -831,10 +836,10 @@ async function handleRebind(deps, projectId, root, mode2) {
831
836
  }
832
837
  }
833
838
  async function handleUnlink(projectBindingPath, mode2) {
834
- const { readFile: readFile8, writeFile: writeFile7, chmod: chmod2 } = await import("fs/promises");
839
+ const { readFile: readFile12, writeFile: writeFile10, chmod: chmod2 } = await import("fs/promises");
835
840
  let existing = {};
836
841
  try {
837
- const raw = await readFile8(projectBindingPath, "utf8");
842
+ const raw = await readFile12(projectBindingPath, "utf8");
838
843
  const parsed = JSON.parse(raw);
839
844
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
840
845
  throw new CliError(
@@ -849,7 +854,7 @@ async function handleUnlink(projectBindingPath, mode2) {
849
854
  }
850
855
  const { projectId: _removed, ...rest } = existing;
851
856
  void _removed;
852
- await writeFile7(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
857
+ await writeFile10(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
853
858
  await chmod2(projectBindingPath, 420);
854
859
  if (mode2 === "json") {
855
860
  printResult({ unlinked: true, projectBindingPath }, mode2);
@@ -906,12 +911,17 @@ Proceeding anyway \u2014 verify the ID is correct.
906
911
  await writeRepoBinding(root, projectId);
907
912
  const writtenPath = path5.join(root, "nolto.json");
908
913
  let registryAdded = false;
909
- try {
910
- registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
911
- } catch (err) {
912
- const message = err instanceof Error ? err.message : String(err);
913
- process.stderr.write(`Warning: could not update watch registry: ${message}
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}
914
923
  `);
924
+ }
915
925
  }
916
926
  if (mode2 === "json") {
917
927
  printResult({
@@ -1004,6 +1014,30 @@ function resolveSkillSourceDir() {
1004
1014
  throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
1005
1015
  }
1006
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
+ }
1007
1041
  async function installSkill(args) {
1008
1042
  const targetDir = path6.join(args.skillsParentDir, "roadmap-progress");
1009
1043
  const markerPath = path6.join(targetDir, VERSION_MARKER);
@@ -1069,14 +1103,55 @@ async function scaffoldRoadmap(args) {
1069
1103
  return { created: true, path: filePath };
1070
1104
  }
1071
1105
 
1106
+ // src/git-merge-driver.ts
1107
+ import { execFile as execFile2 } from "child_process";
1108
+ import { readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
1109
+ import path8 from "path";
1110
+ import { promisify } from "util";
1111
+ var ATTRIBUTE_LINE = ".nolto/roadmaps/*.json merge=nolto-roadmap";
1112
+ var DRIVER_COMMAND = "nolto merge-file %A %B --base %O";
1113
+ async function ensureGitAttributes(root) {
1114
+ const filePath = path8.join(root, ".gitattributes");
1115
+ let content = "";
1116
+ try {
1117
+ content = await readFile5(filePath, "utf8");
1118
+ } catch (err) {
1119
+ if (err.code !== "ENOENT") throw err;
1120
+ }
1121
+ if (content.split(/\r?\n/).includes(ATTRIBUTE_LINE)) return "exists";
1122
+ const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
1123
+ await writeFile6(filePath, content + separator + ATTRIBUTE_LINE + "\n", "utf8");
1124
+ return "added";
1125
+ }
1126
+ async function configureMergeDriver(root, exec = async (file, args, options) => {
1127
+ const execFileAsync = promisify(execFile2);
1128
+ await execFileAsync(file, args, options);
1129
+ }) {
1130
+ try {
1131
+ await exec(
1132
+ "git",
1133
+ ["config", "--local", "merge.nolto-roadmap.name", "Nolto roadmap merge"],
1134
+ { cwd: root }
1135
+ );
1136
+ await exec(
1137
+ "git",
1138
+ ["config", "--local", "merge.nolto-roadmap.driver", DRIVER_COMMAND],
1139
+ { cwd: root }
1140
+ );
1141
+ return "configured";
1142
+ } catch {
1143
+ return "skipped";
1144
+ }
1145
+ }
1146
+
1072
1147
  // src/commands/init.ts
1073
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1148
+ var __dirname2 = path9.dirname(fileURLToPath2(import.meta.url));
1074
1149
  var _require = createRequire(import.meta.url);
1075
1150
  function getCliVersion() {
1076
1151
  const candidates = [
1077
- path8.resolve(__dirname2, "../package.json"),
1152
+ path9.resolve(__dirname2, "../package.json"),
1078
1153
  // bundled: dist/../package.json
1079
- path8.resolve(__dirname2, "../../package.json")
1154
+ path9.resolve(__dirname2, "../../package.json")
1080
1155
  // source: src/commands/../../package.json
1081
1156
  ];
1082
1157
  for (const pkgPath of candidates) {
@@ -1090,161 +1165,219 @@ function getCliVersion() {
1090
1165
  }
1091
1166
  return "0.0.0";
1092
1167
  }
1168
+ async function pickProject(rl, http, projects, promptText) {
1169
+ if (projects.length > 0) {
1170
+ process.stdout.write("\nProjects:\n");
1171
+ projects.forEach((project, index) => {
1172
+ process.stdout.write(` (${index + 1}) ${project.name} \u2014 ${project.id}
1173
+ `);
1174
+ });
1175
+ } else {
1176
+ process.stdout.write("\nNo projects yet.\n");
1177
+ }
1178
+ const pick = await rl.question(promptText);
1179
+ const trimmedPick = pick.trim().toLowerCase();
1180
+ if (trimmedPick === "c") {
1181
+ const name = await rl.question("New project name: ");
1182
+ if (name.trim().length === 0) {
1183
+ throw new CliError("Project name is required.", 2);
1184
+ }
1185
+ const created = await http.post(
1186
+ "/api/projects",
1187
+ { name: name.trim() }
1188
+ );
1189
+ if (created.project?.id == null) {
1190
+ throw new CliError("Project API did not return a project id.", 2);
1191
+ }
1192
+ const project = {
1193
+ id: created.project.id,
1194
+ name: created.project.name ?? name.trim()
1195
+ };
1196
+ process.stdout.write(`Created project ${project.name} (${project.id})
1197
+ `);
1198
+ return project;
1199
+ }
1200
+ const num = parseInt(trimmedPick, 10);
1201
+ if (!isNaN(num) && num >= 1 && num <= projects.length) {
1202
+ return projects[num - 1];
1203
+ }
1204
+ return void 0;
1205
+ }
1093
1206
  function register2(program, deps) {
1094
1207
  program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
1095
1208
  const configPath = deps.configPath;
1096
- if (!opts.force) {
1097
- let existing = null;
1098
- try {
1099
- existing = await loadConfigFile(configPath);
1100
- } catch {
1101
- }
1102
- if (existing != null) {
1103
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
1104
- try {
1105
- const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
1106
- if (answer.trim().toLowerCase() !== "y") {
1107
- process.stdout.write("Cancelled.\n");
1108
- return;
1109
- }
1110
- } finally {
1111
- rl2.close();
1112
- }
1113
- }
1114
- }
1115
1209
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1116
- let token = "";
1117
1210
  try {
1118
- const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
1119
- const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
1120
- const currentToken = deps.settings.token;
1121
- const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
1122
- const enteredToken = await promptHidden(rl, tokenPrompt);
1123
- token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
1124
- if (token.length === 0) {
1125
- throw new CliError("Token is required.", 2);
1126
- }
1127
- const http = createHttpClient({
1128
- baseUrl,
1129
- token,
1130
- version: getCliVersion()
1131
- });
1132
- let projects = [];
1133
- try {
1134
- const result = await http.get("/api/projects");
1135
- projects = Array.isArray(result.projects) ? result.projects : [];
1136
- } catch (err) {
1137
- if (err instanceof CliError && err.exitCode === 3) {
1138
- throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
1211
+ let configureGlobal = opts.force === true;
1212
+ if (!configureGlobal) {
1213
+ let existing = null;
1214
+ try {
1215
+ existing = await loadConfigFile(configPath);
1216
+ } catch {
1217
+ }
1218
+ if (existing == null || deps.settings.token == null) {
1219
+ configureGlobal = true;
1220
+ } else {
1221
+ const answer = await rl.question(
1222
+ `Config already exists at ${configPath}. Reconfigure token and base URL? [y/N] `
1223
+ );
1224
+ configureGlobal = answer.trim().toLowerCase() === "y";
1139
1225
  }
1140
- throw err;
1141
1226
  }
1142
- let defaultProjectId;
1227
+ let token = deps.settings.token ?? "";
1228
+ let baseUrl = deps.settings.baseUrl;
1229
+ let defaultProjectId = deps.settings.defaultProjectId;
1143
1230
  let defaultProjectName;
1144
- if (projects.length > 0) {
1145
- process.stdout.write("\nProjects:\n");
1146
- projects.forEach((p, i) => {
1147
- process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
1148
- `);
1231
+ let http;
1232
+ let projects;
1233
+ if (configureGlobal) {
1234
+ const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
1235
+ baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
1236
+ const currentToken = deps.settings.token;
1237
+ const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
1238
+ const enteredToken = await promptHidden(rl, tokenPrompt);
1239
+ token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
1240
+ if (token.length === 0) {
1241
+ throw new CliError("Token is required.", 2);
1242
+ }
1243
+ http = createHttpClient({
1244
+ baseUrl,
1245
+ token,
1246
+ version: getCliVersion()
1149
1247
  });
1150
- } else {
1151
- process.stdout.write("\nNo projects yet.\n");
1152
- }
1153
- const pick = await rl.question("Default project number, 'c' to create new (or Enter to skip): ");
1154
- const trimmedPick = pick.trim().toLowerCase();
1155
- if (trimmedPick === "c") {
1156
- const name = await rl.question("New project name: ");
1157
- if (name.trim().length === 0) {
1158
- throw new CliError("Project name is required.", 2);
1248
+ try {
1249
+ const result = await http.get("/api/projects");
1250
+ projects = Array.isArray(result.projects) ? result.projects : [];
1251
+ } catch (err) {
1252
+ if (err instanceof CliError && err.exitCode === 3) {
1253
+ throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
1254
+ }
1255
+ throw err;
1159
1256
  }
1160
- const created = await http.post(
1161
- "/api/projects",
1162
- { name: name.trim() }
1257
+ const selected = await pickProject(
1258
+ rl,
1259
+ http,
1260
+ projects,
1261
+ "Default project number, 'c' to create new (or Enter to skip): "
1163
1262
  );
1164
- if (created.project?.id == null) {
1165
- throw new CliError("Project API did not return a project id.", 2);
1166
- }
1167
- defaultProjectId = created.project.id;
1168
- defaultProjectName = created.project.name ?? name.trim();
1169
- process.stdout.write(`Created project ${defaultProjectName} (${defaultProjectId})
1170
- `);
1171
- } else {
1172
- const num = parseInt(trimmedPick, 10);
1173
- if (!isNaN(num) && num >= 1 && num <= projects.length) {
1174
- defaultProjectId = projects[num - 1].id;
1175
- defaultProjectName = projects[num - 1].name;
1176
- }
1177
- }
1178
- await saveConfigFile(configPath, {
1179
- token,
1180
- baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
1181
- defaultProjectId
1182
- });
1183
- const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
1184
- process.stdout.write(`
1263
+ defaultProjectId = selected?.id;
1264
+ defaultProjectName = selected?.name;
1265
+ await saveConfigFile(configPath, {
1266
+ token,
1267
+ baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
1268
+ defaultProjectId
1269
+ });
1270
+ const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
1271
+ process.stdout.write(`
1185
1272
  Saved ${configPath}
1186
1273
  `);
1187
- process.stdout.write(`baseUrl: ${baseUrl}
1274
+ process.stdout.write(`baseUrl: ${baseUrl}
1188
1275
  `);
1189
- process.stdout.write(`token: ${maskToken(token)} (verified)
1276
+ process.stdout.write(`token: ${maskToken(token)} (verified)
1190
1277
  `);
1191
- process.stdout.write(`defaultProject: ${projectDisplay}
1278
+ process.stdout.write(`defaultProject: ${projectDisplay}
1192
1279
  `);
1193
- if (defaultProjectId != null) {
1194
- const startDir = resolveStartDir(process.env, process.cwd());
1195
- const { root, foundGit } = findRepoRoot(startDir);
1196
- if (foundGit) {
1197
- const setup = await rl.question(`
1280
+ } else {
1281
+ const projectDisplay = defaultProjectId ?? "not set";
1282
+ process.stdout.write(
1283
+ `Using existing config: baseUrl=${baseUrl}, token=${maskToken(token)}, defaultProject=${projectDisplay}
1284
+ `
1285
+ );
1286
+ }
1287
+ const startDir = resolveStartDir(process.env, process.cwd());
1288
+ const { root, foundGit } = findRepoRoot(startDir);
1289
+ if (!foundGit) {
1290
+ process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
1291
+ return;
1292
+ }
1293
+ if (isHomeDirectory(root)) {
1294
+ process.stderr.write(
1295
+ `Refusing to set up your home directory as a repository root (found ${path9.join(os3.homedir(), ".git")}). Run nolto init inside a project repository.
1296
+ `
1297
+ );
1298
+ return;
1299
+ }
1300
+ const existingBinding = deps.repoBinding?.error == null ? deps.repoBinding?.binding ?? null : null;
1301
+ let repoProject = existingBinding != null ? { id: existingBinding.projectId, name: path9.basename(root) } : defaultProjectId != null ? { id: defaultProjectId, name: defaultProjectName ?? path9.basename(root) } : void 0;
1302
+ if (repoProject == null) {
1303
+ http ??= createHttpClient({ baseUrl, token, version: getCliVersion() });
1304
+ if (projects == null) {
1305
+ const result = await http.get("/api/projects");
1306
+ projects = Array.isArray(result.projects) ? result.projects : [];
1307
+ }
1308
+ repoProject = await pickProject(
1309
+ rl,
1310
+ http,
1311
+ projects,
1312
+ "Project number for this repository, 'c' to create new (or Enter to skip): "
1313
+ );
1314
+ }
1315
+ if (repoProject == null) {
1316
+ process.stdout.write("Skipped repo setup (no project selected).\n");
1317
+ return;
1318
+ }
1319
+ const setup = await rl.question(`
1198
1320
  Set up this repository (${root}) for roadmap sync? [Y/n] `);
1199
- if (setup.trim().toLowerCase() !== "n") {
1200
- if (deps.repoBinding?.error != null) {
1201
- process.stderr.write(
1202
- `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
1321
+ if (setup.trim().toLowerCase() === "n") {
1322
+ process.stdout.write("Skipped repo setup.\n");
1323
+ return;
1324
+ }
1325
+ const bindingPath = deps.repoBinding?.path ?? path9.join(root, "nolto.json");
1326
+ if (existingBinding != null) {
1327
+ process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
1328
+ `);
1329
+ } else {
1330
+ if (deps.repoBinding?.error != null) {
1331
+ process.stderr.write(
1332
+ `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
1203
1333
  `
1204
- );
1205
- }
1206
- await writeRepoBinding(root, defaultProjectId);
1207
- process.stdout.write(`binding: wrote ${path8.join(root, "nolto.json")}
1334
+ );
1335
+ }
1336
+ await writeRepoBinding(root, repoProject.id);
1337
+ process.stdout.write(`binding: wrote ${path9.join(root, "nolto.json")}
1208
1338
  `);
1209
- const sourceDir = resolveSkillSourceDir();
1210
- const version = getCliVersion();
1211
- const claudeInstall = await installSkill({
1212
- skillsParentDir: path8.join(root, ".claude", "skills"),
1213
- sourceDir,
1214
- version
1215
- });
1216
- process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
1339
+ }
1340
+ const sourceDir = resolveSkillSourceDir();
1341
+ const version = getCliVersion();
1342
+ const claudeInstall = await installSkill({
1343
+ skillsParentDir: path9.join(root, ".claude", "skills"),
1344
+ sourceDir,
1345
+ version
1346
+ });
1347
+ process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
1217
1348
  `);
1218
- const usesAgentsTooling = fs.existsSync(path8.join(root, ".agents")) || fs.existsSync(path8.join(root, ".codex")) || fs.existsSync(path8.join(root, "AGENTS.md"));
1219
- if (usesAgentsTooling) {
1220
- const agentsInstall = await installSkill({
1221
- skillsParentDir: path8.join(root, ".agents", "skills"),
1222
- sourceDir,
1223
- version
1224
- });
1225
- process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
1349
+ const usesAgentsTooling = fs.existsSync(path9.join(root, ".agents")) || fs.existsSync(path9.join(root, ".codex")) || fs.existsSync(path9.join(root, "AGENTS.md"));
1350
+ if (usesAgentsTooling) {
1351
+ const agentsInstall = await installSkill({
1352
+ skillsParentDir: path9.join(root, ".agents", "skills"),
1353
+ sourceDir,
1354
+ version
1355
+ });
1356
+ process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
1226
1357
  `);
1227
- }
1228
- const scaffold = await scaffoldRoadmap({
1229
- repoRoot: root,
1230
- projectName: defaultProjectName ?? path8.basename(root)
1231
- });
1232
- process.stdout.write(
1233
- scaffold.created ? `roadmap: created ${scaffold.path}
1358
+ }
1359
+ const scaffold = await scaffoldRoadmap({
1360
+ repoRoot: root,
1361
+ projectName: repoProject.name
1362
+ });
1363
+ process.stdout.write(
1364
+ scaffold.created ? `roadmap: created ${scaffold.path}
1234
1365
  ` : `roadmap: exists ${scaffold.path}
1235
1366
  `
1236
- );
1237
- const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
1238
- process.stdout.write(
1239
- registryResult.added ? `watch registry: added ${root}
1367
+ );
1368
+ const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
1369
+ process.stdout.write(
1370
+ registryResult.added ? `watch registry: added ${root}
1240
1371
  ` : `watch registry: already registered
1241
1372
  `
1242
- );
1243
- }
1244
- } else {
1245
- process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
1246
- }
1247
- }
1373
+ );
1374
+ const attributesResult = await ensureGitAttributes(root);
1375
+ process.stdout.write(`gitattributes: ${attributesResult} .gitattributes
1376
+ `);
1377
+ const mergeDriverResult = await configureMergeDriver(root, deps.gitMergeExec);
1378
+ process.stdout.write(
1379
+ mergeDriverResult === "configured" ? "merge driver: configured\n" : "merge driver: skipped (git unavailable)\n"
1380
+ );
1248
1381
  } finally {
1249
1382
  rl.close();
1250
1383
  }
@@ -1464,15 +1597,15 @@ function register4(program, deps) {
1464
1597
  }
1465
1598
 
1466
1599
  // src/commands/sync.ts
1467
- import { copyFile, mkdir as mkdir6, readFile as readFile5, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1600
+ import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1468
1601
  import { existsSync as existsSync3 } from "fs";
1469
1602
 
1470
1603
  // src/sync-repo.ts
1471
- import path10 from "path";
1604
+ import path11 from "path";
1472
1605
 
1473
1606
  // src/sync-core.ts
1474
1607
  import { createHash } from "crypto";
1475
- import path9 from "path";
1608
+ import path10 from "path";
1476
1609
  function sha256Hex(content) {
1477
1610
  return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1478
1611
  }
@@ -1493,10 +1626,10 @@ function collectPlanRefs(roadmap) {
1493
1626
  }
1494
1627
  return refs;
1495
1628
  }
1496
- async function loadValidRoadmap(filePath, readFile8) {
1629
+ async function loadValidRoadmap(filePath, readFile12) {
1497
1630
  let raw;
1498
1631
  try {
1499
- raw = await readFile8(filePath);
1632
+ raw = await readFile12(filePath);
1500
1633
  } catch {
1501
1634
  throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1502
1635
  }
@@ -1519,7 +1652,7 @@ async function loadValidRoadmap(filePath, readFile8) {
1519
1652
  async function buildSyncBody(args) {
1520
1653
  const planDocuments = [];
1521
1654
  for (const ref of collectPlanRefs(args.roadmap)) {
1522
- const absolute = path9.join(args.repoRoot, ref.path);
1655
+ const absolute = path10.join(args.repoRoot, ref.path);
1523
1656
  if (!args.deps.fileExists(absolute)) {
1524
1657
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1525
1658
  continue;
@@ -1566,7 +1699,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
1566
1699
  }
1567
1700
  }
1568
1701
  async function migrateLegacyRoadmap(args) {
1569
- const targetPath = path10.join(args.roadmapsDir, `${args.slug}.json`);
1702
+ const targetPath = path11.join(args.roadmapsDir, `${args.slug}.json`);
1570
1703
  await args.io.mkdir(args.roadmapsDir);
1571
1704
  try {
1572
1705
  await args.io.rename(args.legacyPath, targetPath);
@@ -1575,7 +1708,7 @@ async function migrateLegacyRoadmap(args) {
1575
1708
  await args.io.unlink(args.legacyPath);
1576
1709
  }
1577
1710
  try {
1578
- await args.io.rmdir(path10.dirname(args.legacyPath));
1711
+ await args.io.rmdir(path11.dirname(args.legacyPath));
1579
1712
  } catch {
1580
1713
  }
1581
1714
  args.io.log(
@@ -1584,14 +1717,14 @@ async function migrateLegacyRoadmap(args) {
1584
1717
  return `${args.slug}.json`;
1585
1718
  }
1586
1719
  async function syncRepo(args, io) {
1587
- const bindingPath = path10.join(args.root, "nolto.json");
1720
+ const bindingPath = path11.join(args.root, "nolto.json");
1588
1721
  const binding = await loadRepoBinding(bindingPath);
1589
1722
  const projectId = binding?.projectId ?? args.defaultProjectId;
1590
1723
  if (projectId == null) {
1591
1724
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1592
1725
  }
1593
- const roadmapsDir = path10.join(args.root, ".nolto", "roadmaps");
1594
- const legacyPath = path10.join(args.root, ".roadmap", "roadmap.json");
1726
+ const roadmapsDir = path11.join(args.root, ".nolto", "roadmaps");
1727
+ const legacyPath = path11.join(args.root, ".roadmap", "roadmap.json");
1595
1728
  let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1596
1729
  if (roadmapFiles.length > 0) {
1597
1730
  if (io.fileExists(legacyPath)) {
@@ -1600,7 +1733,13 @@ async function syncRepo(args, io) {
1600
1733
  );
1601
1734
  }
1602
1735
  } else if (io.fileExists(legacyPath)) {
1603
- const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path10.basename(args.root));
1736
+ if (args.migrateLegacy !== true) {
1737
+ io.warn(
1738
+ "legacy .roadmap/roadmap.json found \u2014 run `nolto sync` once to migrate it to .nolto/roadmaps/ (watch does not migrate automatically)"
1739
+ );
1740
+ return { results: [], planAbsPaths: [] };
1741
+ }
1742
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path11.basename(args.root));
1604
1743
  roadmapFiles = [
1605
1744
  await migrateLegacyRoadmap({
1606
1745
  slug: migrationSlug,
@@ -1624,14 +1763,14 @@ async function syncRepo(args, io) {
1624
1763
  2
1625
1764
  );
1626
1765
  }
1627
- const filePath = path10.join(roadmapsDir, fileName);
1766
+ const filePath = path11.join(roadmapsDir, fileName);
1628
1767
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1629
1768
  })
1630
1769
  );
1631
1770
  const planAbsPaths = /* @__PURE__ */ new Set();
1632
1771
  for (const { roadmap } of roadmaps) {
1633
1772
  for (const ref of collectPlanRefs(roadmap)) {
1634
- planAbsPaths.add(path10.join(args.root, ref.path));
1773
+ planAbsPaths.add(path11.join(args.root, ref.path));
1635
1774
  }
1636
1775
  }
1637
1776
  const results = [];
@@ -1659,10 +1798,13 @@ function register5(program, deps) {
1659
1798
  throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1660
1799
  }
1661
1800
  const http = deps.http;
1801
+ const warn = (line) => {
1802
+ process.stderr.write("Warning: " + line + "\n");
1803
+ };
1662
1804
  const response = await syncRepo(
1663
- { root, defaultProjectId: deps.settings.defaultProjectId },
1805
+ { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
1664
1806
  {
1665
- readFile: (p) => readFile5(p, "utf8"),
1807
+ readFile: (p) => readFile6(p, "utf8"),
1666
1808
  fileExists: (p) => existsSync3(p),
1667
1809
  listDir: (p) => readdir2(p),
1668
1810
  rename: rename2,
@@ -1673,16 +1815,28 @@ function register5(program, deps) {
1673
1815
  repoIdentity: makeRepoIdentityResolver(deps),
1674
1816
  http,
1675
1817
  log: (line) => process.stdout.write(line + "\n"),
1676
- warn: (line) => process.stderr.write("Warning: " + line + "\n")
1818
+ warn
1677
1819
  }
1678
1820
  );
1679
- let registryAdded = false;
1680
1821
  try {
1681
- registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
1682
- } catch (err) {
1683
- const message = err instanceof Error ? err.message : String(err);
1684
- process.stderr.write(`Warning: could not update watch registry: ${message}
1822
+ const drifted = await checkSkillVersionDrift(root, deps.version);
1823
+ if (drifted.length > 0) {
1824
+ warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
1825
+ }
1826
+ } catch {
1827
+ }
1828
+ let registryAdded = false;
1829
+ if (isHomeDirectory(root)) {
1830
+ process.stderr.write(`Warning: refusing to add home directory ${root} to the watch registry.
1685
1831
  `);
1832
+ } else {
1833
+ try {
1834
+ registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
1835
+ } catch (err) {
1836
+ const message = err instanceof Error ? err.message : String(err);
1837
+ process.stderr.write(`Warning: could not update watch registry: ${message}
1838
+ `);
1839
+ }
1686
1840
  }
1687
1841
  if (deps.output.mode === "json") {
1688
1842
  const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
@@ -1694,10 +1848,531 @@ function register5(program, deps) {
1694
1848
  });
1695
1849
  }
1696
1850
 
1851
+ // src/commands/diff.ts
1852
+ import { readFile as readFile7 } from "fs/promises";
1853
+ import path13 from "path";
1854
+
1855
+ // src/roadmap-diff.ts
1856
+ function diffRoadmaps(local, server) {
1857
+ if (local === null && server === null) return [];
1858
+ if (local === null) return [{ kind: "meta", field: "roadmap", side: "server" }];
1859
+ if (server === null) return [{ kind: "meta", field: "roadmap", side: "local" }];
1860
+ const entries = [];
1861
+ const serverPhases = new Map(server.phases.map((phase) => [phase.id, phase]));
1862
+ for (const localPhase of local.phases) {
1863
+ const serverPhase = serverPhases.get(localPhase.id);
1864
+ if (!serverPhase) {
1865
+ entries.push({
1866
+ kind: "phase-removed",
1867
+ id: localPhase.id,
1868
+ title: localPhase.title,
1869
+ side: "local"
1870
+ });
1871
+ continue;
1872
+ }
1873
+ const serverTasks = new Map(serverPhase.tasks.map((task) => [task.id, task]));
1874
+ for (const localTask of localPhase.tasks) {
1875
+ const serverTask = serverTasks.get(localTask.id);
1876
+ if (!serverTask) {
1877
+ entries.push({
1878
+ kind: "task-removed",
1879
+ id: localTask.id,
1880
+ title: localTask.title,
1881
+ side: "local"
1882
+ });
1883
+ } else if (localTask.status !== serverTask.status) {
1884
+ entries.push({
1885
+ kind: "task-status",
1886
+ id: localTask.id,
1887
+ title: localTask.title,
1888
+ local: localTask.status,
1889
+ server: serverTask.status
1890
+ });
1891
+ }
1892
+ }
1893
+ const localTaskIds = new Set(localPhase.tasks.map((task) => task.id));
1894
+ for (const serverTask of serverPhase.tasks) {
1895
+ if (!localTaskIds.has(serverTask.id)) {
1896
+ entries.push({
1897
+ kind: "task-added",
1898
+ id: serverTask.id,
1899
+ title: serverTask.title,
1900
+ side: "server"
1901
+ });
1902
+ }
1903
+ }
1904
+ }
1905
+ const localPhaseIds = new Set(local.phases.map((phase) => phase.id));
1906
+ for (const serverPhase of server.phases) {
1907
+ if (!localPhaseIds.has(serverPhase.id)) {
1908
+ entries.push({
1909
+ kind: "phase-added",
1910
+ id: serverPhase.id,
1911
+ title: serverPhase.title,
1912
+ side: "server"
1913
+ });
1914
+ }
1915
+ }
1916
+ const meta = [
1917
+ { field: "updatedAt", local: local.updatedAt, server: server.updatedAt },
1918
+ {
1919
+ field: "currentTaskId",
1920
+ local: local.currentTaskId ?? null,
1921
+ server: server.currentTaskId ?? null
1922
+ },
1923
+ { field: "summary", local: local.summary, server: server.summary }
1924
+ ];
1925
+ for (const item of meta) {
1926
+ if (item.local !== item.server) entries.push({ kind: "meta", ...item });
1927
+ }
1928
+ return entries;
1929
+ }
1930
+ function truncate(value) {
1931
+ if (value == null) return "none";
1932
+ const singleLine = value.replace(/\s+/g, " ");
1933
+ return singleLine.length <= 60 ? singleLine : singleLine.slice(0, 57) + "...";
1934
+ }
1935
+ function formatRoadmapDiff(slug, entries) {
1936
+ if (entries.length === 0) return [];
1937
+ const lines = [`${slug}:`];
1938
+ for (const entry of entries) {
1939
+ if (entry.kind === "task-status") {
1940
+ lines.push(` ${entry.id}: ${entry.local} (local) != ${entry.server} (server)`);
1941
+ } else if (entry.kind === "task-added" || entry.kind === "task-removed") {
1942
+ lines.push(` ${entry.side} only task ${entry.id} "${truncate(entry.title)}"`);
1943
+ } else if (entry.kind === "phase-added" || entry.kind === "phase-removed") {
1944
+ lines.push(` ${entry.side} only phase ${entry.id} "${truncate(entry.title)}"`);
1945
+ } else if (entry.field === "roadmap") {
1946
+ lines.push(` ${entry.side} only roadmap`);
1947
+ } else {
1948
+ lines.push(
1949
+ ` ${entry.field}: ${truncate(entry.local)} (local) != ${truncate(entry.server)} (server)`
1950
+ );
1951
+ }
1952
+ }
1953
+ return lines;
1954
+ }
1955
+
1956
+ // src/roadmap-read.ts
1957
+ import { readdir as readdir3 } from "fs/promises";
1958
+ import path12 from "path";
1959
+ async function resolveRoadmapReadContext(deps) {
1960
+ if (deps.settings.token == null) {
1961
+ throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
1962
+ }
1963
+ const startDir = resolveStartDir(process.env, process.cwd());
1964
+ const { root, foundGit } = findRepoRoot(startDir);
1965
+ if (!foundGit) {
1966
+ throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1967
+ }
1968
+ const binding = await loadRepoBinding(path12.join(root, "nolto.json"));
1969
+ const projectId = binding?.projectId ?? deps.settings.defaultProjectId;
1970
+ if (projectId == null) {
1971
+ throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1972
+ }
1973
+ return { root, projectId };
1974
+ }
1975
+ async function listLocalRoadmapSlugs(root) {
1976
+ try {
1977
+ const entries = await readdir3(path12.join(root, ".nolto", "roadmaps"));
1978
+ return entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -".json".length)).sort();
1979
+ } catch (err) {
1980
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
1981
+ return [];
1982
+ }
1983
+ throw err;
1984
+ }
1985
+ }
1986
+
1987
+ // src/commands/diff.ts
1988
+ function register6(program, deps) {
1989
+ program.command("diff [slug]").description("Compare local roadmaps with the server without changing either side.").action(async (requestedSlug) => {
1990
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
1991
+ const list = await deps.http.get(
1992
+ `/api/projects/${projectId}/roadmaps`
1993
+ );
1994
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
1995
+ const localSlugs = await listLocalRoadmapSlugs(root);
1996
+ const localSlugSet = new Set(localSlugs);
1997
+ const allSlugs = [.../* @__PURE__ */ new Set([...localSlugs, ...serverBySlug.keys()])].sort();
1998
+ if (requestedSlug != null && !allSlugs.includes(requestedSlug)) {
1999
+ throw new CliError(
2000
+ `Roadmap "${requestedSlug}" was not found locally or on the server.`,
2001
+ 2
2002
+ );
2003
+ }
2004
+ const targetSlugs = requestedSlug == null ? allSlugs : [requestedSlug];
2005
+ let differing = 0;
2006
+ for (const slug of targetSlugs) {
2007
+ let local = null;
2008
+ if (localSlugSet.has(slug)) {
2009
+ const localPath = path13.join(root, ".nolto", "roadmaps", `${slug}.json`);
2010
+ try {
2011
+ local = await loadValidRoadmap(localPath, (filePath) => readFile7(filePath, "utf8"));
2012
+ } catch (err) {
2013
+ const message = err instanceof Error ? err.message : String(err);
2014
+ process.stderr.write(`Warning: could not diff ${slug}.json: ${message}
2015
+ `);
2016
+ process.stdout.write(`${slug}:
2017
+ local roadmap is present but invalid
2018
+ `);
2019
+ differing += 1;
2020
+ continue;
2021
+ }
2022
+ }
2023
+ let server = null;
2024
+ if (serverBySlug.has(slug) && local !== null) {
2025
+ const response = await deps.http.get(
2026
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(slug)}`
2027
+ );
2028
+ const validation = validateRoadmap(response.roadmap);
2029
+ if (validation.errors.length > 0) {
2030
+ throw new CliError(
2031
+ `Server roadmap "${slug}" failed validation: ${validation.errors.join("; ")}`,
2032
+ 5
2033
+ );
2034
+ }
2035
+ server = response.roadmap;
2036
+ }
2037
+ const entries = local === null && serverBySlug.has(slug) ? [{ kind: "meta", field: "roadmap", side: "server" }] : diffRoadmaps(local, server);
2038
+ if (entries.length === 0) continue;
2039
+ differing += 1;
2040
+ process.stdout.write(formatRoadmapDiff(slug, entries).join("\n") + "\n");
2041
+ }
2042
+ if (differing === 0) {
2043
+ process.stdout.write("Up to date with the server.\n");
2044
+ process.exitCode = 0;
2045
+ } else {
2046
+ process.stdout.write(`${differing} roadmap(s) differ
2047
+ `);
2048
+ process.exitCode = 1;
2049
+ }
2050
+ });
2051
+ }
2052
+
2053
+ // src/commands/pull.ts
2054
+ import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2055
+ import path14 from "path";
2056
+
2057
+ // src/roadmap-merge.ts
2058
+ import { isDeepStrictEqual } from "util";
2059
+ function taskLocations(roadmap) {
2060
+ const result = /* @__PURE__ */ new Map();
2061
+ for (const phase of roadmap?.phases ?? []) {
2062
+ for (const task of phase.tasks) result.set(task.id, { task, phaseId: phase.id });
2063
+ }
2064
+ return result;
2065
+ }
2066
+ function chooseField(args) {
2067
+ if (args.baseExists && isDeepStrictEqual(args.ours, args.base)) return args.theirs;
2068
+ if (args.baseExists && isDeepStrictEqual(args.theirs, args.base)) return args.ours;
2069
+ if (isDeepStrictEqual(args.ours, args.theirs)) return args.ours;
2070
+ return args.newer === "ours" ? args.ours : args.theirs;
2071
+ }
2072
+ function chooseStatus(base, ours, theirs) {
2073
+ if (base !== void 0) {
2074
+ const oursChanged = ours.status !== base.status;
2075
+ const theirsChanged = theirs.status !== base.status;
2076
+ if (!oursChanged) return theirs.status;
2077
+ if (!theirsChanged) return ours.status;
2078
+ }
2079
+ const candidates = [ours, theirs];
2080
+ if (candidates.some((task) => task.status === "done" && task.completedAt != null)) {
2081
+ return "done";
2082
+ }
2083
+ for (const status of ["blocked", "in-progress", "todo"]) {
2084
+ if (candidates.some((task) => task.status === status)) return status;
2085
+ }
2086
+ return base?.status !== "done" ? base?.status ?? "todo" : "todo";
2087
+ }
2088
+ function earliest(values) {
2089
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(a) - Date.parse(b))[0];
2090
+ }
2091
+ function latest(values) {
2092
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
2093
+ }
2094
+ function assignOptional(target, key, value) {
2095
+ if (value !== void 0) Object.assign(target, { [key]: value });
2096
+ }
2097
+ function cloneSingleTask(task) {
2098
+ const cloned = { ...task };
2099
+ if (task.dependsOn !== void 0) cloned.dependsOn = [...task.dependsOn];
2100
+ if (cloned.status !== "done") delete cloned.completedAt;
2101
+ return cloned;
2102
+ }
2103
+ function mergeTask(args) {
2104
+ const baseExists = args.base !== void 0;
2105
+ const status = chooseStatus(args.base, args.ours, args.theirs);
2106
+ const merged = {
2107
+ id: args.ours.id,
2108
+ title: chooseField({
2109
+ baseExists,
2110
+ base: args.base?.title,
2111
+ ours: args.ours.title,
2112
+ theirs: args.theirs.title,
2113
+ newer: args.newer
2114
+ }),
2115
+ status
2116
+ };
2117
+ assignOptional(
2118
+ merged,
2119
+ "startedAt",
2120
+ earliest([args.base?.startedAt, args.ours.startedAt, args.theirs.startedAt])
2121
+ );
2122
+ if (status === "done") {
2123
+ assignOptional(
2124
+ merged,
2125
+ "completedAt",
2126
+ latest([args.base?.completedAt, args.ours.completedAt, args.theirs.completedAt])
2127
+ );
2128
+ }
2129
+ assignOptional(merged, "note", chooseField({
2130
+ baseExists,
2131
+ base: args.base?.note,
2132
+ ours: args.ours.note,
2133
+ theirs: args.theirs.note,
2134
+ newer: args.newer
2135
+ }));
2136
+ assignOptional(merged, "plan", chooseField({
2137
+ baseExists,
2138
+ base: args.base?.plan,
2139
+ ours: args.ours.plan,
2140
+ theirs: args.theirs.plan,
2141
+ newer: args.newer
2142
+ }));
2143
+ const dependsOn = [
2144
+ ...args.ours.dependsOn ?? [],
2145
+ ...args.theirs.dependsOn ?? []
2146
+ ].filter((id, index, values) => values.indexOf(id) === index);
2147
+ if (dependsOn.length > 0) merged.dependsOn = dependsOn;
2148
+ return merged;
2149
+ }
2150
+ function shouldKeep(base, ours, theirs) {
2151
+ if (base === void 0) return ours !== void 0 || theirs !== void 0;
2152
+ if (ours === void 0 && theirs === void 0) return false;
2153
+ if (ours === void 0) return !isDeepStrictEqual(theirs, base);
2154
+ if (theirs === void 0) return !isDeepStrictEqual(ours, base);
2155
+ return true;
2156
+ }
2157
+ function orderTasks(args) {
2158
+ const oursPhase = args.ours.phases.find((phase) => phase.id === args.phaseId);
2159
+ const theirsPhase = args.theirs.phases.find((phase) => phase.id === args.phaseId);
2160
+ const ordered = (oursPhase?.tasks ?? []).map((task) => task.id).filter((id) => args.taskIds.has(id));
2161
+ let nearestCommon = null;
2162
+ let insertAfter = null;
2163
+ for (const task of theirsPhase?.tasks ?? []) {
2164
+ if (!args.taskIds.has(task.id)) continue;
2165
+ if (args.oursLocations.has(task.id)) {
2166
+ if (ordered.includes(task.id)) {
2167
+ nearestCommon = task.id;
2168
+ insertAfter = task.id;
2169
+ }
2170
+ continue;
2171
+ }
2172
+ if (nearestCommon === null || insertAfter === null) {
2173
+ ordered.push(task.id);
2174
+ continue;
2175
+ }
2176
+ const index = ordered.indexOf(insertAfter);
2177
+ ordered.splice(index + 1, 0, task.id);
2178
+ insertAfter = task.id;
2179
+ }
2180
+ return ordered;
2181
+ }
2182
+ function mergeRoadmaps(args) {
2183
+ const newer = Date.parse(args.theirs.updatedAt) > Date.parse(args.ours.updatedAt) ? "theirs" : "ours";
2184
+ const newerRoadmap = newer === "ours" ? args.ours : args.theirs;
2185
+ const olderRoadmap = newer === "ours" ? args.theirs : args.ours;
2186
+ const basePhases = new Map((args.base?.phases ?? []).map((phase) => [phase.id, phase]));
2187
+ const oursPhases = new Map(args.ours.phases.map((phase) => [phase.id, phase]));
2188
+ const theirsPhases = new Map(args.theirs.phases.map((phase) => [phase.id, phase]));
2189
+ const baseLocations = taskLocations(args.base);
2190
+ const oursLocations = taskLocations(args.ours);
2191
+ const theirsLocations = taskLocations(args.theirs);
2192
+ const keptTasks = /* @__PURE__ */ new Map();
2193
+ const targetPhaseByTask = /* @__PURE__ */ new Map();
2194
+ const taskIds = /* @__PURE__ */ new Set([
2195
+ ...baseLocations.keys(),
2196
+ ...oursLocations.keys(),
2197
+ ...theirsLocations.keys()
2198
+ ]);
2199
+ for (const taskId of taskIds) {
2200
+ const baseLocation = baseLocations.get(taskId);
2201
+ const oursLocation = oursLocations.get(taskId);
2202
+ const theirsLocation = theirsLocations.get(taskId);
2203
+ if (!shouldKeep(baseLocation, oursLocation, theirsLocation)) continue;
2204
+ const task = oursLocation !== void 0 && theirsLocation !== void 0 ? mergeTask({
2205
+ base: baseLocation?.task,
2206
+ ours: oursLocation.task,
2207
+ theirs: theirsLocation.task,
2208
+ newer
2209
+ }) : cloneSingleTask((oursLocation ?? theirsLocation).task);
2210
+ keptTasks.set(taskId, task);
2211
+ targetPhaseByTask.set(taskId, (oursLocation ?? theirsLocation).phaseId);
2212
+ }
2213
+ const keptPhaseIds = /* @__PURE__ */ new Set();
2214
+ const allPhaseIds = /* @__PURE__ */ new Set([
2215
+ ...basePhases.keys(),
2216
+ ...oursPhases.keys(),
2217
+ ...theirsPhases.keys()
2218
+ ]);
2219
+ for (const phaseId of allPhaseIds) {
2220
+ if (shouldKeep(basePhases.get(phaseId), oursPhases.get(phaseId), theirsPhases.get(phaseId))) {
2221
+ keptPhaseIds.add(phaseId);
2222
+ }
2223
+ }
2224
+ const phaseOrder = [
2225
+ ...args.ours.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id)),
2226
+ ...args.theirs.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id) && !oursPhases.has(id))
2227
+ ];
2228
+ const phases = phaseOrder.map((phaseId) => {
2229
+ const base = basePhases.get(phaseId);
2230
+ const ours = oursPhases.get(phaseId);
2231
+ const theirs = theirsPhases.get(phaseId);
2232
+ const source = ours ?? theirs;
2233
+ const title = ours !== void 0 && theirs !== void 0 ? chooseField({
2234
+ baseExists: base !== void 0,
2235
+ base: base?.title,
2236
+ ours: ours.title,
2237
+ theirs: theirs.title,
2238
+ newer
2239
+ }) : source.title;
2240
+ const plan = ours !== void 0 && theirs !== void 0 ? chooseField({
2241
+ baseExists: base !== void 0,
2242
+ base: base?.plan,
2243
+ ours: ours.plan,
2244
+ theirs: theirs.plan,
2245
+ newer
2246
+ }) : source.plan;
2247
+ const phaseTaskIds = new Set(
2248
+ [...keptTasks.keys()].filter((taskId) => targetPhaseByTask.get(taskId) === phaseId)
2249
+ );
2250
+ const tasks = orderTasks({
2251
+ phaseId,
2252
+ taskIds: phaseTaskIds,
2253
+ ours: args.ours,
2254
+ theirs: args.theirs,
2255
+ oursLocations
2256
+ }).map((taskId) => keptTasks.get(taskId));
2257
+ const phase = { id: phaseId, title, status: "todo", tasks };
2258
+ if (plan !== void 0) phase.plan = plan;
2259
+ phase.status = derivePhaseStatus(phase);
2260
+ return phase;
2261
+ });
2262
+ const mergedTasks = new Map(
2263
+ phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task]))
2264
+ );
2265
+ const validCurrentTask = (roadmap) => {
2266
+ const id = roadmap.currentTaskId ?? null;
2267
+ return id != null && mergedTasks.get(id)?.status === "in-progress" ? id : null;
2268
+ };
2269
+ const merged = {
2270
+ schemaVersion: 2,
2271
+ project: { ...newerRoadmap.project },
2272
+ updatedAt: newerRoadmap.updatedAt,
2273
+ currentTaskId: validCurrentTask(newerRoadmap) ?? validCurrentTask(olderRoadmap),
2274
+ summary: newerRoadmap.summary,
2275
+ phases
2276
+ };
2277
+ const validation = validateRoadmap(merged);
2278
+ if (validation.errors.length > 0) {
2279
+ throw new CliError(
2280
+ `Merged roadmap failed validation:
2281
+ ${validation.errors.join("\n ")}`,
2282
+ 1
2283
+ );
2284
+ }
2285
+ return merged;
2286
+ }
2287
+
2288
+ // src/commands/pull.ts
2289
+ async function readExisting(filePath) {
2290
+ try {
2291
+ return await readFile8(filePath, "utf8");
2292
+ } catch (err) {
2293
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
2294
+ return null;
2295
+ }
2296
+ throw err;
2297
+ }
2298
+ }
2299
+ function register7(program, deps) {
2300
+ program.command("pull [slug]").description(
2301
+ "Pull server roadmaps, optionally merging them with valid local copies."
2302
+ ).option("--merge", "Structurally merge server and local roadmap changes").action(async (requestedSlug, opts) => {
2303
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
2304
+ const list = await deps.http.get(
2305
+ `/api/projects/${projectId}/roadmaps`
2306
+ );
2307
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
2308
+ if (requestedSlug != null && !serverBySlug.has(requestedSlug)) {
2309
+ throw new CliError(`Roadmap "${requestedSlug}" was not found on the server.`, 2);
2310
+ }
2311
+ const targets = requestedSlug == null ? [...serverBySlug.values()] : [serverBySlug.get(requestedSlug)];
2312
+ const roadmapsDir = path14.join(root, ".nolto", "roadmaps");
2313
+ for (const summary of targets) {
2314
+ const response = await deps.http.get(
2315
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(summary.slug)}`
2316
+ );
2317
+ const validation = validateRoadmap(response.roadmap);
2318
+ if (validation.errors.length > 0) {
2319
+ throw new CliError(
2320
+ `Server roadmap "${summary.slug}" failed validation: ${validation.errors.join("; ")}`,
2321
+ 5
2322
+ );
2323
+ }
2324
+ const filePath = path14.join(roadmapsDir, `${summary.slug}.json`);
2325
+ const existing = await readExisting(filePath);
2326
+ let roadmap = response.roadmap;
2327
+ let outputVerb = "pulled";
2328
+ if (opts.merge === true && existing !== null) {
2329
+ let local;
2330
+ try {
2331
+ local = JSON.parse(existing);
2332
+ } catch {
2333
+ process.stderr.write(
2334
+ `Warning: skipped ${summary.slug}.json because the local roadmap contains malformed JSON.
2335
+ `
2336
+ );
2337
+ continue;
2338
+ }
2339
+ const localValidation = validateRoadmap(local);
2340
+ if (localValidation.errors.length > 0) {
2341
+ process.stderr.write(
2342
+ `Warning: skipped ${summary.slug}.json because the local roadmap is invalid: ${localValidation.errors.join("; ")}
2343
+ `
2344
+ );
2345
+ continue;
2346
+ }
2347
+ roadmap = mergeRoadmaps({
2348
+ base: null,
2349
+ ours: local,
2350
+ theirs: response.roadmap
2351
+ });
2352
+ outputVerb = "merged";
2353
+ }
2354
+ const serialized = JSON.stringify(roadmap, null, 2) + "\n";
2355
+ if (existing === serialized) {
2356
+ process.stdout.write(`unchanged ${summary.slug}.json
2357
+ `);
2358
+ continue;
2359
+ }
2360
+ await mkdir7(roadmapsDir, { recursive: true });
2361
+ await writeFile7(filePath, serialized, "utf8");
2362
+ process.stdout.write(
2363
+ outputVerb === "merged" ? `merged ${summary.slug}.json
2364
+ ` : `pulled ${summary.slug}.json (${summary.taskDone}/${summary.taskTotal} done)
2365
+ `
2366
+ );
2367
+ }
2368
+ process.stdout.write("Review with git diff before committing.\n");
2369
+ });
2370
+ }
2371
+
1697
2372
  // src/commands/watch.ts
1698
- 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";
2373
+ import { copyFile as copyFile2, mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
1699
2374
  import { existsSync as existsSync4 } from "fs";
1700
- import path12 from "path";
2375
+ import path16 from "path";
1701
2376
  import chokidar from "chokidar";
1702
2377
 
1703
2378
  // src/watch-core.ts
@@ -1803,8 +2478,8 @@ var RepoWatch = class {
1803
2478
  };
1804
2479
 
1805
2480
  // src/service-install.ts
1806
- import path11 from "path";
1807
- import os2 from "os";
2481
+ import path15 from "path";
2482
+ import os4 from "os";
1808
2483
  function buildUnitFile(args) {
1809
2484
  return [
1810
2485
  "[Unit]",
@@ -1823,8 +2498,8 @@ function buildUnitFile(args) {
1823
2498
  }
1824
2499
  function getUnitPath(env) {
1825
2500
  const xdg = env["XDG_CONFIG_HOME"];
1826
- const base = xdg != null && xdg.length > 0 ? xdg : path11.join(os2.homedir(), ".config");
1827
- return path11.join(base, "systemd", "user", "nolto-watch.service");
2501
+ const base = xdg != null && xdg.length > 0 ? xdg : path15.join(os4.homedir(), ".config");
2502
+ return path15.join(base, "systemd", "user", "nolto-watch.service");
1828
2503
  }
1829
2504
  async function installServiceWith(deps) {
1830
2505
  if (deps.platform !== "linux") {
@@ -1835,7 +2510,7 @@ async function installServiceWith(deps) {
1835
2510
  );
1836
2511
  }
1837
2512
  const unitPath = getUnitPath(deps.env);
1838
- await deps.mkdir(path11.dirname(unitPath));
2513
+ await deps.mkdir(path15.dirname(unitPath));
1839
2514
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1840
2515
  deps.log(`Wrote ${unitPath}`);
1841
2516
  const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
@@ -1849,18 +2524,18 @@ async function installServiceWith(deps) {
1849
2524
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1850
2525
  }
1851
2526
  async function installService() {
1852
- const { writeFile: writeFile7, mkdir: mkdir9 } = await import("fs/promises");
1853
- const { execFile: execFile3 } = await import("child_process");
1854
- const { promisify: promisify2 } = await import("util");
1855
- const execFileAsync = promisify2(execFile3);
2527
+ const { writeFile: writeFile10, mkdir: mkdir10 } = await import("fs/promises");
2528
+ const { execFile: execFile4 } = await import("child_process");
2529
+ const { promisify: promisify3 } = await import("util");
2530
+ const execFileAsync = promisify3(execFile4);
1856
2531
  await installServiceWith({
1857
2532
  platform: process.platform,
1858
2533
  env: process.env,
1859
2534
  nodePath: process.execPath,
1860
- scriptPath: path11.resolve(process.argv[1] ?? ""),
1861
- writeFile: (p, content) => writeFile7(p, content, "utf8"),
2535
+ scriptPath: path15.resolve(process.argv[1] ?? ""),
2536
+ writeFile: (p, content) => writeFile10(p, content, "utf8"),
1862
2537
  mkdir: async (p) => {
1863
- await mkdir9(p, { recursive: true });
2538
+ await mkdir10(p, { recursive: true });
1864
2539
  },
1865
2540
  exec: async (cmd) => {
1866
2541
  try {
@@ -1915,9 +2590,9 @@ async function uninstallServiceWith(deps) {
1915
2590
  }
1916
2591
  async function uninstallService() {
1917
2592
  const { unlink: unlink4 } = await import("fs/promises");
1918
- const { execFile: execFile3 } = await import("child_process");
1919
- const { promisify: promisify2 } = await import("util");
1920
- const execFileAsync = promisify2(execFile3);
2593
+ const { execFile: execFile4 } = await import("child_process");
2594
+ const { promisify: promisify3 } = await import("util");
2595
+ const execFileAsync = promisify3(execFile4);
1921
2596
  await uninstallServiceWith({
1922
2597
  platform: process.platform,
1923
2598
  env: process.env,
@@ -1937,7 +2612,7 @@ async function uninstallService() {
1937
2612
  }
1938
2613
 
1939
2614
  // src/commands/watch.ts
1940
- function register6(program, deps) {
2615
+ function register8(program, deps) {
1941
2616
  program.command("watch").description("Watch every registered repository's roadmap + plan files and sync on change.").option("--debounce <ms>", "Debounce window in milliseconds", "2000").option("--install-service", "Install and enable a systemd user unit (nolto-watch) instead of watching").option("--uninstall-service", "Stop and remove the systemd user unit (nolto-watch) instead of watching").action(async (opts) => {
1942
2617
  if (opts.installService && opts.uninstallService) {
1943
2618
  throw new CliError("--install-service and --uninstall-service cannot be used together.", 2);
@@ -1974,27 +2649,31 @@ function register6(program, deps) {
1974
2649
  token: deps.settings.token
1975
2650
  });
1976
2651
  const startRepo = (root) => {
1977
- const roadmapsPath = path12.join(root, ".nolto", "roadmaps");
1978
- const legacyRoadmapPath = path12.join(root, ".roadmap", "roadmap.json");
2652
+ const roadmapsPath = path16.join(root, ".nolto", "roadmaps");
2653
+ const legacyRoadmapPath = path16.join(root, ".roadmap", "roadmap.json");
2654
+ const warn = (line) => {
2655
+ process.stderr.write(`Warning: [${path16.basename(root)}] ${line}
2656
+ `);
2657
+ };
1979
2658
  const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
1980
2659
  const repoWatch = new RepoWatch(root, {
1981
2660
  sync: () => syncRepo(
1982
- { root, defaultProjectId: deps.settings.defaultProjectId },
2661
+ // #316: watch must warn about legacy roadmaps without migrating them.
2662
+ { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
1983
2663
  {
1984
- readFile: (p) => readFile6(p, "utf8"),
2664
+ readFile: (p) => readFile9(p, "utf8"),
1985
2665
  fileExists: (p) => existsSync4(p),
1986
- listDir: (p) => readdir3(p),
2666
+ listDir: (p) => readdir4(p),
1987
2667
  rename: rename3,
1988
2668
  copyFile: copyFile2,
1989
- mkdir: (p) => mkdir7(p, { recursive: true }).then(() => void 0),
2669
+ mkdir: (p) => mkdir8(p, { recursive: true }).then(() => void 0),
1990
2670
  unlink: unlink3,
1991
2671
  rmdir: rmdir2,
1992
2672
  repoIdentity: makeRepoIdentityResolver(deps),
1993
2673
  http,
1994
- log: (line) => process.stdout.write(`[${path12.basename(root)}] ${line}
2674
+ log: (line) => process.stdout.write(`[${path16.basename(root)}] ${line}
1995
2675
  `),
1996
- warn: (line) => process.stderr.write(`Warning: [${path12.basename(root)}] ${line}
1997
- `)
2676
+ warn
1998
2677
  }
1999
2678
  ),
2000
2679
  watcher,
@@ -2005,6 +2684,12 @@ function register6(program, deps) {
2005
2684
  warn: (line) => process.stderr.write(line + "\n")
2006
2685
  });
2007
2686
  watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
2687
+ void checkSkillVersionDrift(root, deps.version).then((drifted) => {
2688
+ if (drifted.length > 0) {
2689
+ warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
2690
+ }
2691
+ }).catch(() => {
2692
+ });
2008
2693
  void repoWatch.flush();
2009
2694
  return { stop: () => watcher.close() };
2010
2695
  };
@@ -2049,25 +2734,25 @@ function register6(program, deps) {
2049
2734
  }
2050
2735
 
2051
2736
  // src/update-cli.ts
2052
- import { execFile as execFile2 } from "child_process";
2737
+ import { execFile as execFile3 } from "child_process";
2053
2738
  import { existsSync as existsSync5 } from "fs";
2054
2739
  import { realpath } from "fs/promises";
2055
2740
  import { createRequire as createRequire2 } from "module";
2056
- import path14 from "path";
2741
+ import path18 from "path";
2057
2742
  import { fileURLToPath as fileURLToPath3 } from "url";
2058
- import { promisify } from "util";
2743
+ import { promisify as promisify2 } from "util";
2059
2744
 
2060
2745
  // src/update-notifier.ts
2061
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir8 } from "fs/promises";
2746
+ import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir9 } from "fs/promises";
2062
2747
  import https from "https";
2063
- import path13 from "path";
2748
+ import path17 from "path";
2064
2749
  var PACKAGE = "@nolto/cli";
2065
2750
  var CACHE_FILE = "update-check.json";
2066
2751
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2067
2752
  var REQUEST_TIMEOUT_MS = 2e3;
2068
- function isNewerVersion(latest, current) {
2753
+ function isNewerVersion(latest2, current) {
2069
2754
  const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
2070
- const a = parts(latest);
2755
+ const a = parts(latest2);
2071
2756
  const b = parts(current);
2072
2757
  for (let i = 0; i < 3; i++) {
2073
2758
  const x = a[i] ?? 0;
@@ -2076,9 +2761,9 @@ function isNewerVersion(latest, current) {
2076
2761
  }
2077
2762
  return false;
2078
2763
  }
2079
- function formatUpdateNotice(latest, current) {
2764
+ function formatUpdateNotice(latest2, current) {
2080
2765
  return `
2081
- Update available: ${current} \u2192 ${latest} \xB7 run \`nolto update\`
2766
+ Update available: ${current} \u2192 ${latest2} \xB7 run \`nolto update\`
2082
2767
  `;
2083
2768
  }
2084
2769
  function isDisabled(env) {
@@ -2117,22 +2802,22 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
2117
2802
  req.on("error", () => resolve(null));
2118
2803
  });
2119
2804
  }
2120
- async function writeUpdateCache(cachePath, now, latest) {
2121
- await mkdir8(path13.dirname(cachePath), { recursive: true });
2122
- const payload = { checkedAt: now, latest };
2123
- await writeFile6(cachePath, JSON.stringify(payload), { mode: 384 });
2805
+ async function writeUpdateCache(cachePath, now, latest2) {
2806
+ await mkdir9(path17.dirname(cachePath), { recursive: true });
2807
+ const payload = { checkedAt: now, latest: latest2 };
2808
+ await writeFile8(cachePath, JSON.stringify(payload), { mode: 384 });
2124
2809
  }
2125
2810
  async function refreshCache(cachePath, now, fetchLatest) {
2126
- const latest = await fetchLatest();
2127
- if (!latest) return;
2128
- await writeUpdateCache(cachePath, now, latest).catch(() => void 0);
2811
+ const latest2 = await fetchLatest();
2812
+ if (!latest2) return;
2813
+ await writeUpdateCache(cachePath, now, latest2).catch(() => void 0);
2129
2814
  }
2130
2815
  async function checkForUpdate(opts) {
2131
2816
  if (isDisabled(opts.env)) return null;
2132
- const cachePath = path13.join(opts.configDir, CACHE_FILE);
2817
+ const cachePath = path17.join(opts.configDir, CACHE_FILE);
2133
2818
  let cache = {};
2134
2819
  try {
2135
- cache = JSON.parse(await readFile7(cachePath, "utf8"));
2820
+ cache = JSON.parse(await readFile10(cachePath, "utf8"));
2136
2821
  } catch {
2137
2822
  }
2138
2823
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2148,13 +2833,13 @@ async function checkForUpdate(opts) {
2148
2833
  async function notifyUpdate(opts) {
2149
2834
  try {
2150
2835
  if (opts.isJson || !process.stderr.isTTY) return;
2151
- const latest = await checkForUpdate({
2836
+ const latest2 = await checkForUpdate({
2152
2837
  current: opts.current,
2153
2838
  configDir: getConfigDir(opts.env),
2154
2839
  env: opts.env,
2155
2840
  now: opts.now
2156
2841
  });
2157
- if (latest) process.stderr.write(formatUpdateNotice(latest, opts.current));
2842
+ if (latest2) process.stderr.write(formatUpdateNotice(latest2, opts.current));
2158
2843
  } catch {
2159
2844
  }
2160
2845
  }
@@ -2205,14 +2890,14 @@ async function updateCliWith(deps) {
2205
2890
  if (!isInsideGlobalPackage(deps.scriptPath, globalRoot, deps.platform)) {
2206
2891
  throw notGlobalError(deps.scriptPath);
2207
2892
  }
2208
- const latest = await deps.fetchLatest();
2209
- if (latest == null) {
2893
+ const latest2 = await deps.fetchLatest();
2894
+ if (latest2 == null) {
2210
2895
  throw new CliError(
2211
2896
  "Could not reach the npm registry to check for the latest @nolto/cli version.",
2212
2897
  5
2213
2898
  );
2214
2899
  }
2215
- if (!isNewerVersion(latest, deps.currentVersion)) {
2900
+ if (!isNewerVersion(latest2, deps.currentVersion)) {
2216
2901
  deps.log(`Already up to date (${PACKAGE2} ${deps.currentVersion}).`);
2217
2902
  return {
2218
2903
  status: "up-to-date",
@@ -2225,7 +2910,7 @@ async function updateCliWith(deps) {
2225
2910
  "npm",
2226
2911
  "install",
2227
2912
  "-g",
2228
- `${PACKAGE2}@${latest}`
2913
+ `${PACKAGE2}@${latest2}`
2229
2914
  ]);
2230
2915
  if (installResult.code !== 0) {
2231
2916
  const detail = stderrTail(installResult.stderr);
@@ -2235,11 +2920,11 @@ async function updateCliWith(deps) {
2235
2920
  /EACCES|permission denied/i.test(installResult.stderr) ? "Permission denied \u2014 check your npm global prefix (npm config get prefix) or re-run with elevated permissions." : void 0
2236
2921
  );
2237
2922
  }
2238
- deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2923
+ deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest2}.`);
2239
2924
  await deps.writeCache(
2240
- path14.join(deps.configDir, UPDATE_CACHE_FILE),
2925
+ path18.join(deps.configDir, UPDATE_CACHE_FILE),
2241
2926
  deps.now,
2242
- latest
2927
+ latest2
2243
2928
  ).catch(() => void 0);
2244
2929
  let watchService = "not-installed";
2245
2930
  const unitPath = getUnitPath(deps.env);
@@ -2263,22 +2948,22 @@ async function updateCliWith(deps) {
2263
2948
  return {
2264
2949
  status: "updated",
2265
2950
  from: deps.currentVersion,
2266
- to: latest,
2951
+ to: latest2,
2267
2952
  watchService
2268
2953
  };
2269
2954
  }
2270
2955
  function getCurrentVersion() {
2271
- const dirname = path14.dirname(fileURLToPath3(import.meta.url));
2956
+ const dirname = path18.dirname(fileURLToPath3(import.meta.url));
2272
2957
  const require3 = createRequire2(import.meta.url);
2273
2958
  try {
2274
- const pkg = require3(path14.resolve(dirname, "../package.json"));
2959
+ const pkg = require3(path18.resolve(dirname, "../package.json"));
2275
2960
  return pkg.version ?? "0.0.0";
2276
2961
  } catch {
2277
2962
  return "0.0.0";
2278
2963
  }
2279
2964
  }
2280
2965
  async function updateCli(opts = {}) {
2281
- const execFileAsync = promisify(execFile2);
2966
+ const execFileAsync = promisify2(execFile3);
2282
2967
  const scriptPath = await realpath(process.argv[1] ?? "");
2283
2968
  return updateCliWith({
2284
2969
  currentVersion: getCurrentVersion(),
@@ -2310,7 +2995,7 @@ async function updateCli(opts = {}) {
2310
2995
  }
2311
2996
 
2312
2997
  // src/commands/update.ts
2313
- function register7(program, deps) {
2998
+ function register9(program, deps) {
2314
2999
  program.command("update").description("Update @nolto/cli to the latest version and restart the watch service if installed").action(async () => {
2315
3000
  const mode2 = deps.output.mode;
2316
3001
  const result = await updateCli({ quiet: mode2 === "json" });
@@ -2320,6 +3005,46 @@ function register7(program, deps) {
2320
3005
  });
2321
3006
  }
2322
3007
 
3008
+ // src/commands/merge-file.ts
3009
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3010
+ async function readRoadmap(filePath) {
3011
+ let parsed;
3012
+ try {
3013
+ parsed = JSON.parse(await readFile11(filePath, "utf8"));
3014
+ } catch (err) {
3015
+ const message = err instanceof SyntaxError ? "malformed JSON" : String(err);
3016
+ throw new CliError(`${filePath}: ${message}`, 1);
3017
+ }
3018
+ const validation = validateRoadmap(parsed);
3019
+ if (validation.errors.length > 0) {
3020
+ throw new CliError(
3021
+ `${filePath} failed validation:
3022
+ ${validation.errors.join("\n ")}`,
3023
+ 1
3024
+ );
3025
+ }
3026
+ return parsed;
3027
+ }
3028
+ function register10(program) {
3029
+ program.command("merge-file <ours> <theirs>").description(
3030
+ 'Structurally merge roadmap files for Git.\ngit config merge.nolto-roadmap.driver "nolto merge-file %A %B --base %O"'
3031
+ ).option("--base <path>", "Common ancestor roadmap file").option("--output <path>", "Write the result here (defaults to <ours>)").action(async (oursPath, theirsPath, opts) => {
3032
+ const ours = await readRoadmap(oursPath);
3033
+ const theirs = await readRoadmap(theirsPath);
3034
+ const base = opts.base == null ? null : await readRoadmap(opts.base);
3035
+ const merged = mergeRoadmaps({ base, ours, theirs });
3036
+ await writeFile9(
3037
+ opts.output ?? oursPath,
3038
+ JSON.stringify(merged, null, 2) + "\n",
3039
+ "utf8"
3040
+ );
3041
+ const taskCount = merged.phases.reduce((total, phase) => total + phase.tasks.length, 0);
3042
+ process.stderr.write(`merged roadmap (${taskCount} tasks)
3043
+ `);
3044
+ process.exitCode = 0;
3045
+ });
3046
+ }
3047
+
2323
3048
  // src/program.ts
2324
3049
  function stripCommanderErrorPrefix(msg) {
2325
3050
  return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
@@ -2335,9 +3060,12 @@ function buildProgram(deps) {
2335
3060
  register5(program, deps);
2336
3061
  register6(program, deps);
2337
3062
  register7(program, deps);
3063
+ register8(program, deps);
3064
+ register9(program, deps);
3065
+ register10(program);
2338
3066
  program.hook("preAction", (_thisCommand, actionCommand) => {
2339
3067
  const bindingError = deps.repoBinding?.error;
2340
- const bindingExemptCommands = ["init", "link", "update"];
3068
+ const bindingExemptCommands = ["init", "link", "update", "merge-file"];
2341
3069
  if (bindingError != null && !bindingExemptCommands.includes(actionCommand.name())) {
2342
3070
  throw bindingError;
2343
3071
  }
@@ -2346,11 +3074,11 @@ function buildProgram(deps) {
2346
3074
  }
2347
3075
 
2348
3076
  // src/index.ts
2349
- var __dirname3 = path15.dirname(fileURLToPath4(import.meta.url));
3077
+ var __dirname3 = path19.dirname(fileURLToPath4(import.meta.url));
2350
3078
  var require2 = createRequire3(import.meta.url);
2351
3079
  function getVersion() {
2352
3080
  try {
2353
- const pkgPath = path15.resolve(__dirname3, "../package.json");
3081
+ const pkgPath = path19.resolve(__dirname3, "../package.json");
2354
3082
  const pkg = require2(pkgPath);
2355
3083
  return pkg.version ?? "0.0.0";
2356
3084
  } catch {