@nolto/cli 0.11.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"]),
@@ -551,6 +552,39 @@ function isValidPlanPath(value) {
551
552
  if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
552
553
  return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
553
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
+ }
554
588
  function validatePlanPath(value, at) {
555
589
  if (typeof value !== "string" || value.length === 0) {
556
590
  return [`${at} must be a non-empty string.`];
@@ -586,10 +620,10 @@ function validateRoadmap(value) {
586
620
  const project = value["project"];
587
621
  checkKeys(project, ALLOWED_KEYS.project, "project", errors);
588
622
  const projectRecord = isRecord(project) ? project : {};
589
- if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
590
- if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
591
- if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
592
- 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"));
593
627
  if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
594
628
  if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
595
629
  const allIds = /* @__PURE__ */ new Set();
@@ -599,11 +633,14 @@ function validateRoadmap(value) {
599
633
  const at = `phases[${phaseIndex}]`;
600
634
  checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
601
635
  const phase = isRecord(rawPhase) ? rawPhase : {};
602
- const phaseId = String(phase["id"] ?? "");
603
- if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
604
- if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
605
- allIds.add(phaseId);
606
- 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.`);
607
644
  if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
608
645
  checkPlan(phase["plan"], at, errors);
609
646
  if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
@@ -612,34 +649,64 @@ function validateRoadmap(value) {
612
649
  const taskAt = `${at}.tasks[${taskIndex}]`;
613
650
  checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
614
651
  const task = isRecord(rawTask) ? rawTask : {};
615
- const taskId = String(task["id"] ?? "");
616
- if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
617
- if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
618
- allIds.add(taskId);
619
- tasks.set(taskId, task);
620
- 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.`);
621
661
  if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
622
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
+ }
623
669
  if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
624
670
  if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
625
- 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
+ }
626
688
  }
627
- if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
689
+ if (Array.isArray(phase["tasks"]) && rawTasks.every(isRecord) && STATUSES.has(String(phase["status"]))) {
628
690
  const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
629
691
  if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
630
692
  }
631
693
  }
632
694
  for (const task of tasks.values()) {
633
695
  for (const dependency of task.dependsOn ?? []) {
696
+ if (typeof dependency !== "string") continue;
634
697
  if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
635
698
  if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
636
699
  }
637
700
  }
638
701
  const currentTaskId = value["currentTaskId"];
639
702
  if (currentTaskId !== null && currentTaskId !== void 0) {
640
- const current = tasks.get(String(currentTaskId));
641
- if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
642
- 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
+ }
643
710
  }
644
711
  return { errors, warnings };
645
712
  }
@@ -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,7 +54,7 @@ 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.
@@ -63,10 +63,10 @@ Use this reference when creating or structurally editing `.nolto/roadmaps/<slug>
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
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.
67
- - `completedAt`: Required in practice for `done` tasks.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",