@nolto/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,18 +79,25 @@ paths, and sends an idempotent full upsert to the bound project. A legacy
79
79
  `.roadmap/roadmap.json` is migrated by `nolto sync` when no canonical roadmap
80
80
  exists; `nolto watch` only warns and never migrates.
81
81
 
82
+ Timestamp fields (`updatedAt`, `startedAt`, and `completedAt`) must be RFC 3339 with a timezone offset (`2026-09-02T10:00:00+09:00` or `2026-09-02T01:00:00Z`); offset-less and date-only values are rejected. `dependsOn` items must be unique strings matching `^[a-z0-9][a-z0-9._-]*$`, and names, titles, and IDs must be actual strings.
83
+
84
+ The shared proxy rejects a sync request body over 10 MB with HTTP `413`. Free
85
+ plan documents are already limited to 2 MB per roadmap before that shared cap.
86
+
82
87
  `watch` uses the repository registry maintained by `nolto init`. Missing registered
83
88
  repositories are skipped with a warning.
84
89
  The CLI warns when an installed `roadmap-progress` skill version differs from the
85
90
  CLI version; run `nolto init` in that repository to refresh it.
86
91
 
87
- Use `nolto diff [slug]` before pushing to review structural task, phase, and
88
- metadata differences. It exits with code 1 when differences are found and does
89
- not change either side. `nolto pull [slug]` downloads all server roadmaps, or one
90
- named roadmap, and intentionally overwrites the corresponding local files after
91
- validation. It never deletes local-only roadmaps. Add `--merge` to preserve valid
92
- local changes using a deterministic structural merge, and review the result with
93
- `git diff` before committing.
92
+ Use `nolto diff [slug]` before pushing to compare task status changes, added or
93
+ removed tasks and phases, and roadmap `updatedAt`, `currentTaskId`, and `summary`.
94
+ It does not compare `title`, `note`, `plan`, `dependsOn`, project metadata, or
95
+ ordering; use `git diff` for those. It exits with code 1 when differences are
96
+ found and does not change either side. `nolto pull [slug]` downloads all server
97
+ roadmaps, or one named roadmap, and intentionally overwrites the corresponding
98
+ local files after validation. It never deletes local-only roadmaps. Add `--merge`
99
+ to preserve valid local changes using a deterministic structural merge, and review
100
+ the result with `git diff` before committing.
94
101
 
95
102
  ## Merging roadmap conflicts
96
103
 
@@ -109,6 +116,32 @@ again for an existing repository. Git invokes the underlying command as:
109
116
  nolto merge-file <ours> <theirs> --base <ancestor>
110
117
  ```
111
118
 
119
+ ### Silent resolution rules
120
+
121
+ The merge driver emits no conflict markers and exits `0` unless the merged
122
+ output fails validation, in which case it exits `1`. Resolution is deterministic:
123
+
124
+ - For fields changed on both sides, the side with the newer `updatedAt` wins.
125
+ - `currentTaskId` comes from the newer side while it still points to an
126
+ `in-progress` task; otherwise the older side's value is used if it still
127
+ points to an `in-progress` task, and it is omitted when neither does.
128
+ - Task status priority is `done` (with `completedAt`) > `blocked` >
129
+ `in-progress` > `todo`.
130
+ - `startedAt` keeps the earliest value, `completedAt` keeps the latest value,
131
+ and `dependsOn` is a deduplicated union.
132
+ - A deletion is honored only when both sides deleted the item.
133
+ - Phase order is ours first, followed by phases found only in theirs; phase
134
+ status is re-derived from the merged tasks.
135
+
136
+ Full diff coverage and interactive conflict display are tracked in
137
+ [#475](https://github.com/uruca-kk/nolto/issues/475).
138
+
139
+ ### Legacy roadmap schema v1
140
+
141
+ Schema v1 remains accepted with a warning and has no sunset date. The next
142
+ `roadmap-progress` mutation upgrades it to v2 automatically. This compatibility
143
+ policy remains in place through CLI 1.0 and will be reconsidered then.
144
+
112
145
  The result is written to `<ours>` as required by Git. Use
113
146
  `nolto pull --merge [slug]` when the server has changes that should be combined
114
147
  with a valid local roadmap before syncing.
@@ -177,7 +210,7 @@ Errors are written to stderr:
177
210
  | 1 | Roadmap differences found (`nolto diff`) or unexpected command failure |
178
211
  | 2 | Local input or file validation error |
179
212
  | 3 | Authentication or authorization error |
180
- | 4 | Rate limit response |
213
+ | 4 | Rate limit response, or plan / quota limit (HTTP 402) |
181
214
  | 5 | Network, DNS, or server error |
182
215
 
183
216
  ## License
package/dist/index.js CHANGED
@@ -531,6 +531,7 @@ function normalizeRemote(raw) {
531
531
  // ../roadmap-schema/src/index.ts
532
532
  var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
533
533
  var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
534
+ var DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/;
534
535
  var ALLOWED_KEYS = {
535
536
  roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
536
537
  project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
@@ -546,11 +547,58 @@ function checkKeys(value, allowed, at, errors) {
546
547
  if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
547
548
  }
548
549
  }
549
- function checkPlan(value, at, errors) {
550
- if (value === void 0) return;
550
+ function isValidPlanPath(value) {
551
+ if (typeof value !== "string" || value.length === 0) return false;
552
+ if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
553
+ return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
554
+ }
555
+ function daysInMonth(year, month) {
556
+ if (month === 2) {
557
+ const leap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
558
+ return leap ? 29 : 28;
559
+ }
560
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
561
+ }
562
+ function isValidDateTime(value) {
563
+ if (typeof value !== "string") return false;
564
+ const m = DATE_TIME_PATTERN.exec(value);
565
+ if (m === null) return false;
566
+ const year = Number(m[1]);
567
+ const month = Number(m[2]);
568
+ const day = Number(m[3]);
569
+ const hour = Number(m[4]);
570
+ const minute = Number(m[5]);
571
+ const second = Number(m[6]);
572
+ if (month < 1 || month > 12) return false;
573
+ if (day < 1 || day > daysInMonth(year, month)) return false;
574
+ if (hour > 23 || minute > 59 || second > 59) return false;
575
+ if (m[7] !== void 0) {
576
+ const offsetHour = Number(m[8]);
577
+ const offsetMinute = Number(m[9]);
578
+ if (offsetHour > 23 || offsetMinute > 59) return false;
579
+ }
580
+ return true;
581
+ }
582
+ function validateDateTime(value, at) {
583
+ if (!isValidDateTime(value)) {
584
+ return [`${at} must be an RFC 3339 date-time with timezone offset, e.g. 2026-09-02T10:00:00+09:00.`];
585
+ }
586
+ return [];
587
+ }
588
+ function validatePlanPath(value, at) {
551
589
  if (typeof value !== "string" || value.length === 0) {
552
- errors.push(`${at}.plan must be a non-empty string.`);
590
+ return [`${at} must be a non-empty string.`];
591
+ }
592
+ if (!isValidPlanPath(value)) {
593
+ return [
594
+ `${at} must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).`
595
+ ];
553
596
  }
597
+ return [];
598
+ }
599
+ function checkPlan(value, at, errors) {
600
+ if (value === void 0) return;
601
+ errors.push(...validatePlanPath(value, `${at}.plan`));
554
602
  }
555
603
  function derivePhaseStatus(phase) {
556
604
  if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
@@ -572,10 +620,10 @@ function validateRoadmap(value) {
572
620
  const project = value["project"];
573
621
  checkKeys(project, ALLOWED_KEYS.project, "project", errors);
574
622
  const projectRecord = isRecord(project) ? project : {};
575
- if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
576
- if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
577
- if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
578
- if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
623
+ if (typeof projectRecord["id"] !== "string" || !ID_PATTERN.test(projectRecord["id"])) errors.push("project.id is invalid.");
624
+ if (typeof projectRecord["name"] !== "string" || projectRecord["name"].length === 0) errors.push("project.name must be a non-empty string.");
625
+ if (typeof projectRecord["repository"] !== "string" || projectRecord["repository"].length === 0) errors.push("project.repository must be a non-empty string.");
626
+ errors.push(...validateDateTime(value["updatedAt"], "updatedAt"));
579
627
  if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
580
628
  if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
581
629
  const allIds = /* @__PURE__ */ new Set();
@@ -585,11 +633,14 @@ function validateRoadmap(value) {
585
633
  const at = `phases[${phaseIndex}]`;
586
634
  checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
587
635
  const phase = isRecord(rawPhase) ? rawPhase : {};
588
- const phaseId = String(phase["id"] ?? "");
589
- if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
590
- if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
591
- allIds.add(phaseId);
592
- if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
636
+ const phaseId = typeof phase["id"] === "string" ? phase["id"] : "";
637
+ if (typeof phase["id"] !== "string" || !ID_PATTERN.test(phase["id"])) {
638
+ errors.push(`${at}.id is invalid.`);
639
+ } else {
640
+ if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
641
+ allIds.add(phaseId);
642
+ }
643
+ if (typeof phase["title"] !== "string" || phase["title"].length === 0) errors.push(`${at}.title must be a non-empty string.`);
593
644
  if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
594
645
  checkPlan(phase["plan"], at, errors);
595
646
  if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
@@ -598,34 +649,64 @@ function validateRoadmap(value) {
598
649
  const taskAt = `${at}.tasks[${taskIndex}]`;
599
650
  checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
600
651
  const task = isRecord(rawTask) ? rawTask : {};
601
- const taskId = String(task["id"] ?? "");
602
- if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
603
- if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
604
- allIds.add(taskId);
605
- tasks.set(taskId, task);
606
- if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
652
+ const taskId = typeof task["id"] === "string" ? task["id"] : "";
653
+ if (typeof task["id"] !== "string" || !ID_PATTERN.test(task["id"])) {
654
+ errors.push(`${taskAt}.id is invalid.`);
655
+ } else {
656
+ if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
657
+ allIds.add(taskId);
658
+ tasks.set(taskId, task);
659
+ }
660
+ if (typeof task["title"] !== "string" || task["title"].length === 0) errors.push(`${taskAt}.title must be a non-empty string.`);
607
661
  if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
608
662
  checkPlan(task["plan"], taskAt, errors);
663
+ if (task["startedAt"] !== void 0 && task["startedAt"] !== null) {
664
+ errors.push(...validateDateTime(task["startedAt"], `${taskAt}.startedAt`));
665
+ }
666
+ if (task["completedAt"] !== void 0 && task["completedAt"] !== null) {
667
+ errors.push(...validateDateTime(task["completedAt"], `${taskAt}.completedAt`));
668
+ }
609
669
  if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
610
670
  if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
611
- if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
671
+ if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) {
672
+ errors.push(`${taskAt}.dependsOn must be an array.`);
673
+ } else if (Array.isArray(task["dependsOn"])) {
674
+ const seenDeps = /* @__PURE__ */ new Set();
675
+ for (const [i, dep] of task["dependsOn"].entries()) {
676
+ if (typeof dep !== "string") {
677
+ errors.push(`${taskAt}.dependsOn[${i}] must be a string.`);
678
+ continue;
679
+ }
680
+ if (!ID_PATTERN.test(dep)) {
681
+ errors.push(`${taskAt}.dependsOn[${i}] is invalid.`);
682
+ continue;
683
+ }
684
+ if (seenDeps.has(dep)) errors.push(`${taskAt}.dependsOn contains duplicate id "${dep}".`);
685
+ seenDeps.add(dep);
686
+ }
687
+ }
612
688
  }
613
- if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
689
+ if (Array.isArray(phase["tasks"]) && rawTasks.every(isRecord) && STATUSES.has(String(phase["status"]))) {
614
690
  const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
615
691
  if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
616
692
  }
617
693
  }
618
694
  for (const task of tasks.values()) {
619
695
  for (const dependency of task.dependsOn ?? []) {
696
+ if (typeof dependency !== "string") continue;
620
697
  if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
621
698
  if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
622
699
  }
623
700
  }
624
701
  const currentTaskId = value["currentTaskId"];
625
702
  if (currentTaskId !== null && currentTaskId !== void 0) {
626
- const current = tasks.get(String(currentTaskId));
627
- if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
628
- else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
703
+ if (typeof currentTaskId !== "string") {
704
+ errors.push("currentTaskId must be a string id or null.");
705
+ } else {
706
+ const current = tasks.get(currentTaskId);
707
+ if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
708
+ else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
709
+ }
629
710
  }
630
711
  return { errors, warnings };
631
712
  }
@@ -1671,7 +1752,7 @@ function register4(program, deps) {
1671
1752
  }
1672
1753
 
1673
1754
  // src/commands/sync.ts
1674
- import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1755
+ import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, realpath, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1675
1756
  import { existsSync as existsSync3 } from "fs";
1676
1757
 
1677
1758
  // src/sync-repo.ts
@@ -1726,11 +1807,31 @@ async function loadValidRoadmap(filePath, readFile12) {
1726
1807
  async function buildSyncBody(args) {
1727
1808
  const planDocuments = [];
1728
1809
  for (const ref of collectPlanRefs(args.roadmap)) {
1729
- const absolute = path10.join(args.repoRoot, ref.path);
1810
+ const root = path10.resolve(args.repoRoot);
1811
+ const absolute = path10.resolve(root, ref.path);
1812
+ if (absolute !== root && !absolute.startsWith(root + path10.sep)) {
1813
+ args.deps.warn(`plan path outside repository root, skipping: ${ref.path}`);
1814
+ continue;
1815
+ }
1730
1816
  if (!args.deps.fileExists(absolute)) {
1731
1817
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1732
1818
  continue;
1733
1819
  }
1820
+ if (args.deps.realpath !== void 0) {
1821
+ let real;
1822
+ let realRoot;
1823
+ try {
1824
+ real = await args.deps.realpath(absolute);
1825
+ realRoot = await args.deps.realpath(root);
1826
+ } catch {
1827
+ args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1828
+ continue;
1829
+ }
1830
+ if (real !== realRoot && !real.startsWith(realRoot + path10.sep)) {
1831
+ args.deps.warn(`symlink escapes repository root, skipping: ${ref.path}`);
1832
+ continue;
1833
+ }
1834
+ }
1734
1835
  const content = await args.deps.readFile(absolute);
1735
1836
  planDocuments.push({
1736
1837
  path: ref.path,
@@ -1761,6 +1862,21 @@ async function runSync(args, deps) {
1761
1862
 
1762
1863
  // src/sync-repo.ts
1763
1864
  var ROADMAP_SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
1865
+ function collectContainedPlanAbsPaths(roadmaps, root, warn) {
1866
+ const planAbsPaths = /* @__PURE__ */ new Set();
1867
+ const resolvedRoot = path11.resolve(root);
1868
+ for (const roadmap of roadmaps) {
1869
+ for (const ref of collectPlanRefs(roadmap)) {
1870
+ const absolute = path11.resolve(resolvedRoot, ref.path);
1871
+ if (absolute !== resolvedRoot && !absolute.startsWith(resolvedRoot + path11.sep)) {
1872
+ warn(`chokidar target outside repo, skipping: ${ref.path}`);
1873
+ continue;
1874
+ }
1875
+ planAbsPaths.add(absolute);
1876
+ }
1877
+ }
1878
+ return [...planAbsPaths];
1879
+ }
1764
1880
  async function listRoadmapFiles(roadmapsDir, io) {
1765
1881
  try {
1766
1882
  const entries = await io.listDir(roadmapsDir);
@@ -1841,23 +1957,29 @@ async function syncRepo(args, io) {
1841
1957
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1842
1958
  })
1843
1959
  );
1844
- const planAbsPaths = /* @__PURE__ */ new Set();
1845
- for (const { roadmap } of roadmaps) {
1846
- for (const ref of collectPlanRefs(roadmap)) {
1847
- planAbsPaths.add(path11.join(args.root, ref.path));
1848
- }
1849
- }
1960
+ const planAbsPaths = collectContainedPlanAbsPaths(
1961
+ roadmaps.map(({ roadmap }) => roadmap),
1962
+ args.root,
1963
+ io.warn
1964
+ );
1850
1965
  const results = [];
1851
1966
  const repoIdentity = await io.repoIdentity(args.root);
1852
1967
  for (const { slug, roadmap } of roadmaps) {
1853
1968
  results.push(
1854
1969
  await runSync(
1855
1970
  { repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
1856
- { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1971
+ {
1972
+ http: io.http,
1973
+ readFile: io.readFile,
1974
+ fileExists: io.fileExists,
1975
+ realpath: io.realpath,
1976
+ log: io.log,
1977
+ warn: io.warn
1978
+ }
1857
1979
  )
1858
1980
  );
1859
1981
  }
1860
- return { results, planAbsPaths: [...planAbsPaths] };
1982
+ return { results, planAbsPaths };
1861
1983
  }
1862
1984
 
1863
1985
  // src/commands/sync.ts
@@ -1879,6 +2001,7 @@ function register5(program, deps) {
1879
2001
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
1880
2002
  {
1881
2003
  readFile: (p) => readFile6(p, "utf8"),
2004
+ realpath: (p) => realpath(p),
1882
2005
  fileExists: (p) => existsSync3(p),
1883
2006
  listDir: (p) => readdir2(p),
1884
2007
  rename: rename2,
@@ -2444,7 +2567,7 @@ function register7(program, deps) {
2444
2567
  }
2445
2568
 
2446
2569
  // src/commands/watch.ts
2447
- 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";
2570
+ import { copyFile as copyFile2, mkdir as mkdir8, readFile as readFile9, readdir as readdir4, realpath as realpath2, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
2448
2571
  import { existsSync as existsSync4 } from "fs";
2449
2572
  import path16 from "path";
2450
2573
  import chokidar from "chokidar";
@@ -2736,6 +2859,7 @@ function register8(program, deps) {
2736
2859
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
2737
2860
  {
2738
2861
  readFile: (p) => readFile9(p, "utf8"),
2862
+ realpath: (p) => realpath2(p),
2739
2863
  fileExists: (p) => existsSync4(p),
2740
2864
  listDir: (p) => readdir4(p),
2741
2865
  rename: rename3,
@@ -2810,7 +2934,7 @@ function register8(program, deps) {
2810
2934
  // src/update-cli.ts
2811
2935
  import { execFile as execFile3 } from "child_process";
2812
2936
  import { existsSync as existsSync5 } from "fs";
2813
- import { realpath } from "fs/promises";
2937
+ import { realpath as realpath3 } from "fs/promises";
2814
2938
  import { createRequire as createRequire2 } from "module";
2815
2939
  import path18 from "path";
2816
2940
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -3038,7 +3162,7 @@ function getCurrentVersion() {
3038
3162
  }
3039
3163
  async function updateCli(opts = {}) {
3040
3164
  const execFileAsync = promisify2(execFile3);
3041
- const scriptPath = await realpath(process.argv[1] ?? "");
3165
+ const scriptPath = await realpath3(process.argv[1] ?? "");
3042
3166
  return updateCliWith({
3043
3167
  currentVersion: getCurrentVersion(),
3044
3168
  scriptPath,
@@ -3059,7 +3183,7 @@ async function updateCli(opts = {}) {
3059
3183
  };
3060
3184
  }
3061
3185
  },
3062
- realpath,
3186
+ realpath: realpath3,
3063
3187
  fetchLatest: () => fetchLatestFromRegistry(UPDATE_REQUEST_TIMEOUT_MS, UPDATE_FETCH_OPTIONS),
3064
3188
  writeCache: writeUpdateCache,
3065
3189
  unitExists: existsSync5,
@@ -78,6 +78,7 @@ Use direct JSON edits to add, remove, rename, or reorder phases and tasks, then
78
78
  - Preserve task IDs across title changes so `dependsOn` remains stable.
79
79
  - Keep exactly one `currentTaskId`; it must point to an `in-progress` task. Use `null` when nothing is active.
80
80
  - Require `completedAt` for done tasks and `startedAt` for in-progress tasks.
81
+ - Timestamps must be RFC 3339 with a timezone offset (`2026-09-02T10:00:00+09:00` or `2026-09-02T01:00:00Z`; never `2026-09-02T10:00:00`); `dependsOn` items must be plain task ID strings and unique.
81
82
  - Keep notes factual and current; replace stale implementation notes instead of accumulating a log.
82
83
  - Never rewrite repository history or source code solely to make the roadmap appear complete.
83
84
  - Finish every mutation by running `validate` and reporting the task status changed.
@@ -54,19 +54,19 @@ Use this reference when creating or structurally editing `.nolto/roadmaps/<slug>
54
54
  - `project.id`: Stable lowercase identifier matching `^[a-z0-9][a-z0-9._-]*$`.
55
55
  - `project.name`: Display name.
56
56
  - `project.repository`: Absolute repository path when known.
57
- - `updatedAt`: Date-time of the last meaningful roadmap mutation.
57
+ - `updatedAt`: RFC 3339 date-time with a timezone offset (for example, `2026-07-20T14:30:00+09:00` or `2026-07-20T05:30:00Z`). Offset-less values (`2026-07-20T14:30:00`) and date-only values (`2026-07-20`) are rejected.
58
58
  - `currentTaskId`: One active task ID or `null`.
59
59
  - `summary`: One or two current sentences; do not use as a changelog.
60
60
  - `phases`: Ordered delivery phases.
61
61
  - `phase.status`: `todo`, `in-progress`, `done`, or `blocked`.
62
- - `phase.plan`: Optional repository-relative path to a markdown plan document.
62
+ - `phase.plan`: Optional repository-relative path (`/`-separated) to a Markdown plan document, for example `docs/plans/auth.md`. Absolute paths, backslashes, and `.`/`..` segments are rejected.
63
63
  - `tasks`: Ordered work items. Each must be small enough to verify clearly.
64
64
  - `task.status`: `todo`, `in-progress`, `done`, or `blocked`.
65
- - `task.plan`: Optional repository-relative path to a markdown plan document.
66
- - `startedAt`: Required in practice for `in-progress` tasks.
67
- - `completedAt`: Required in practice for `done` tasks.
65
+ - `task.plan`: Optional repository-relative path (`/`-separated) to a Markdown plan document, for example `docs/plans/auth.md`. Absolute paths, backslashes, and `.`/`..` segments are rejected.
66
+ - `startedAt`: Required in practice for `in-progress` tasks. Uses the same RFC 3339 date-time format as `updatedAt`.
67
+ - `completedAt`: Required in practice for `done` tasks. Uses the same RFC 3339 date-time format as `updatedAt`.
68
68
  - `note`: Current implementation detail or blocker. Omit when it adds no value.
69
- - `dependsOn`: IDs of prerequisite tasks in the same roadmap.
69
+ - `dependsOn`: IDs of prerequisite tasks in the same roadmap. Each item must be a string matching `^[a-z0-9][a-z0-9._-]*$`; duplicates are rejected.
70
70
 
71
71
  ## Phase status derivation
72
72
 
@@ -10,6 +10,14 @@ const ALLOWED_KEYS = {
10
10
  phase: new Set(["id", "title", "status", "plan", "tasks"]),
11
11
  task: new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
12
12
  };
13
+ const PLAN_PATH_ERROR = "plan --path must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).";
14
+
15
+ // Keep in sync with packages/roadmap-schema/src/index.ts isValidPlanPath.
16
+ function isValidPlanPath(value) {
17
+ if (typeof value !== "string" || value.length === 0) return false;
18
+ if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
19
+ return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
20
+ }
13
21
 
14
22
  function parseArguments(argv) {
15
23
  const options = { file: null, note: undefined, summary: undefined, text: undefined, path: undefined, positional: [] };
@@ -258,15 +266,20 @@ try {
258
266
  } else if (command === "plan") {
259
267
  if (!taskId) throw new Error("plan requires a task id.");
260
268
  if (!options.path) throw new Error("plan requires --path <repo-relative-md-path>.");
269
+ if (!isValidPlanPath(options.path)) throw new Error(PLAN_PATH_ERROR);
270
+ const repoRoot = path.resolve(repoRootForRoadmap(filePath));
271
+ const resolvedPlanPath = path.resolve(repoRoot, options.path);
272
+ if (resolvedPlanPath !== repoRoot && !resolvedPlanPath.startsWith(repoRoot + path.sep)) {
273
+ throw new Error(PLAN_PATH_ERROR);
274
+ }
261
275
  const { task } = findTask(roadmap, taskId);
262
276
  task.plan = options.path;
263
277
  roadmap.schemaVersion = 2;
264
278
  roadmap.updatedAt = localIsoNow();
265
- const repoRoot = repoRootForRoadmap(filePath);
266
279
  try {
267
- await access(path.join(repoRoot, options.path));
280
+ await access(resolvedPlanPath);
268
281
  } catch {
269
- console.warn(`WARN plan file not found at ${path.join(repoRoot, options.path)}`);
282
+ console.warn(`WARN plan file not found at ${resolvedPlanPath}`);
270
283
  }
271
284
  const result = validate(roadmap);
272
285
  if (result.errors.length) throw new Error(`Mutation failed validation: ${result.errors.join(" ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",