@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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
590
|
-
if (projectRecord["name"]
|
|
591
|
-
if (projectRecord["repository"]
|
|
592
|
-
|
|
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 =
|
|
603
|
-
if (!ID_PATTERN.test(
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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 =
|
|
616
|
-
if (!ID_PATTERN.test(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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"]))
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
else
|
|
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`:
|
|
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
|
|