@chriskealley/openroad 0.1.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/LICENSE +9 -0
- package/README.md +101 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/config.d.ts +6 -0
- package/dist/src/config.js +74 -0
- package/dist/src/installer.d.ts +6 -0
- package/dist/src/installer.js +93 -0
- package/dist/src/roadmap.d.ts +10 -0
- package/dist/src/roadmap.js +70 -0
- package/dist/src/types.d.ts +19 -0
- package/dist/src/types.js +1 -0
- package/package.json +32 -0
- package/skills/openroad/SKILL.md +19 -0
- package/skills/openroad-next/SKILL.md +20 -0
- package/templates/roadmap.md +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Kealley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# OpenRoad
|
|
2
|
+
|
|
3
|
+
A companion CLI that connects a project-level roadmap to [OpenSpec](https://openspec.dev/) without forking OpenSpec or modifying its generated skills.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22 or later, with npm and `npx` available.
|
|
8
|
+
- An existing project initialized with OpenSpec. The project must contain `openspec/config.yaml`, `openspec/specs/`, and `openspec/changes/`.
|
|
9
|
+
- At least one supported agent/tool if you want skills installed automatically: Codex, Pi, Claude, or Cursor.
|
|
10
|
+
- Write access to the target project. A global installation may also require permission to write to npm's global package directory.
|
|
11
|
+
|
|
12
|
+
Initialize OpenSpec in the target project before installing this integration:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
cd path/to/your-project
|
|
16
|
+
openspec init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Run with npx
|
|
20
|
+
|
|
21
|
+
You can run the CLI without installing it globally:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx @chriskealley/openroad init --tools codex,pi,claude,cursor
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To install only for Codex, for example:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx @chriskealley/openroad init --tools codex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Install globally
|
|
34
|
+
|
|
35
|
+
Install the CLI globally if you plan to use it across several OpenSpec projects:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install --global @chriskealley/openroad
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then run it from the root of an initialized OpenSpec project:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
openroad init --tools codex,pi,claude,cursor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If npm reports a permissions error during global installation, configure an npm-managed user-level global directory or use the `npx` option above; avoid running npm with `sudo`.
|
|
48
|
+
|
|
49
|
+
## What installation changes
|
|
50
|
+
|
|
51
|
+
If `--tools` is omitted, the CLI detects existing `.codex`, `.pi`, `.claude`, and `.cursor` directories. It then:
|
|
52
|
+
|
|
53
|
+
- Creates `openspec/roadmap.md` if it does not already exist.
|
|
54
|
+
- Installs the `openroad` and `openroad-next` skills for each selected tool.
|
|
55
|
+
- Adds narrowly scoped context, rules, and archive guidance to `openspec/config.yaml`.
|
|
56
|
+
- Records managed files in `.openroad.json` so updates and removal are safe.
|
|
57
|
+
|
|
58
|
+
Existing roadmap content is preserved during updates. Removal preserves `openspec/roadmap.md` and removes only this package's exact configuration guidance and managed skill files.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
openroad init
|
|
64
|
+
openroad update
|
|
65
|
+
openroad doctor
|
|
66
|
+
openroad remove
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
All commands accept `--root <path>`; the default is the current directory. `init` and `update` accept `--tools <comma-separated-list>` using any combination of `codex`, `pi`, `claude`, and `cursor`.
|
|
70
|
+
|
|
71
|
+
## Roadmap semantics
|
|
72
|
+
|
|
73
|
+
Lifecycle statuses are `planned`, `ready`, `active`, `done`, and `cancelled`. Only active items have a work state: `available`, `blocked`, or `paused`. Multiple items may be active simultaneously. `openroad-next` selects the eligible ready item with the lowest numeric priority, after checking dependencies, regardless of other blocked or paused active work.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
The CLI is TypeScript compiled to ES modules. Node.js 22 or later is required to build and test; the repository has one runtime dependency (`yaml`) and no build tooling beyond `tsc`.
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
git clone https://github.com/chriskealley/openroad.git
|
|
81
|
+
cd openroad
|
|
82
|
+
npm install
|
|
83
|
+
npm test
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Command | Purpose |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `npm run build` | Compile `src/` and `test/` to `dist/` |
|
|
89
|
+
| `npm test` | Build, then run the suite with the Node test runner |
|
|
90
|
+
| `npm run typecheck` | Type-check without emitting output |
|
|
91
|
+
|
|
92
|
+
Sources live in [src/](src/); `cli.ts` handles argument parsing, `installer.ts` manages the managed-file lifecycle and `.openroad.json` manifest, `config.ts` merges and removes the OpenSpec `config.yaml` guidance, and `roadmap.ts` parses and validates `roadmap.md`. The roadmap template shipped to new projects is [templates/roadmap.md](templates/roadmap.md), and the agent skills are in [skills/](skills/).
|
|
93
|
+
|
|
94
|
+
Two behaviours are worth preserving when changing the installer or parser, and both are covered by tests: installing and removing must leave an existing `openspec/config.yaml` byte-identical, and the shipped roadmap template must validate cleanly under `openroad doctor`.
|
|
95
|
+
|
|
96
|
+
To try a change against a real project, pack the tarball and install it rather than linking, so you exercise the same layout users get:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm pack
|
|
100
|
+
npm install --global ./chriskealley-openroad-*.tgz
|
|
101
|
+
```
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { install, readManifest, remove } from "./installer.js";
|
|
4
|
+
import { validateRoadmap } from "./roadmap.js";
|
|
5
|
+
const VALID_TOOLS = new Set(["codex", "pi", "claude", "cursor"]);
|
|
6
|
+
function usage() {
|
|
7
|
+
console.log(`openroad <command> [options]\n\nCommands:\n init Install roadmap integration\n update Refresh managed integration files\n doctor Validate installation and roadmap\n remove Remove integration (preserves roadmap.md)\n\nOptions:\n --root <path> Project root (default: current directory)\n --tools <list> Comma-separated: codex,pi,claude,cursor`);
|
|
8
|
+
}
|
|
9
|
+
function option(name) {
|
|
10
|
+
const index = process.argv.indexOf(name);
|
|
11
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
12
|
+
}
|
|
13
|
+
async function main() {
|
|
14
|
+
const command = process.argv[2];
|
|
15
|
+
if (!command || command === "--help" || command === "-h")
|
|
16
|
+
return usage();
|
|
17
|
+
const root = resolve(option("--root") ?? process.cwd());
|
|
18
|
+
const toolsText = option("--tools");
|
|
19
|
+
const tools = toolsText?.split(",").map(value => value.trim());
|
|
20
|
+
if (tools?.some(tool => !VALID_TOOLS.has(tool)))
|
|
21
|
+
throw new Error("Unknown tool in --tools; use codex, pi, claude, or cursor");
|
|
22
|
+
if (command === "init" || command === "update") {
|
|
23
|
+
if (command === "update" && !await readManifest(root))
|
|
24
|
+
throw new Error("Not installed; run `openroad init` first");
|
|
25
|
+
const manifest = await install(root, tools);
|
|
26
|
+
console.log(`${command === "init" ? "Installed" : "Updated"} OpenRoad for ${manifest.consumers.join(", ") || "no detected skill consumers"}.`);
|
|
27
|
+
}
|
|
28
|
+
else if (command === "doctor") {
|
|
29
|
+
const manifest = await readManifest(root);
|
|
30
|
+
if (!manifest)
|
|
31
|
+
throw new Error("Manifest is missing; run `openroad init`");
|
|
32
|
+
const result = await validateRoadmap(join(root, "openspec/roadmap.md"));
|
|
33
|
+
if (result.errors.length)
|
|
34
|
+
throw new Error(`Roadmap validation failed:\n- ${result.errors.join("\n- ")}`);
|
|
35
|
+
console.log(`Healthy: ${result.items.length} roadmap item(s), ${manifest.consumers.length} skill consumer(s).`);
|
|
36
|
+
}
|
|
37
|
+
else if (command === "remove") {
|
|
38
|
+
await remove(root);
|
|
39
|
+
console.log("Removed OpenRoad integration. openspec/roadmap.md was preserved.");
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
throw new Error(`Unknown command: ${command}`);
|
|
43
|
+
}
|
|
44
|
+
main().catch(error => { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const CONTEXT_LINE = "Roadmap coordination: read openspec/roadmap.md before proposing, applying, or archiving changes. Multiple changes may be active concurrently; lifecycle Status and active Work state are separate.";
|
|
2
|
+
export declare const PROPOSAL_RULE = "Link the proposal to exactly one roadmap item and record the OpenSpec change name on that item when work becomes active.";
|
|
3
|
+
export declare const TASKS_RULE = "Respect roadmap dependencies and blocked or paused work states; do not treat another active change as preventing concurrent work.";
|
|
4
|
+
export declare const ARCHIVE_GUIDANCE = "After a successful archive, mark the linked roadmap item done and clear its active Work state; do not change unrelated roadmap items.";
|
|
5
|
+
export declare function mergeConfig(path: string): Promise<void>;
|
|
6
|
+
export declare function removeConfig(path: string): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { parseDocument, YAMLMap, YAMLSeq, Scalar } from "yaml";
|
|
3
|
+
export const CONTEXT_LINE = "Roadmap coordination: read openspec/roadmap.md before proposing, applying, or archiving changes. Multiple changes may be active concurrently; lifecycle Status and active Work state are separate.";
|
|
4
|
+
export const PROPOSAL_RULE = "Link the proposal to exactly one roadmap item and record the OpenSpec change name on that item when work becomes active.";
|
|
5
|
+
export const TASKS_RULE = "Respect roadmap dependencies and blocked or paused work states; do not treat another active change as preventing concurrent work.";
|
|
6
|
+
export const ARCHIVE_GUIDANCE = "After a successful archive, mark the linked roadmap item done and clear its active Work state; do not change unrelated roadmap items.";
|
|
7
|
+
function sequence(doc, values) {
|
|
8
|
+
const seq = new YAMLSeq();
|
|
9
|
+
seq.items = values.map(value => new Scalar(value));
|
|
10
|
+
return seq;
|
|
11
|
+
}
|
|
12
|
+
function addUnique(doc, path, value) {
|
|
13
|
+
const current = doc.getIn(path, true);
|
|
14
|
+
if (current instanceof YAMLSeq) {
|
|
15
|
+
if (!current.items.some(item => String(item.value) === value))
|
|
16
|
+
current.add(value);
|
|
17
|
+
}
|
|
18
|
+
else if (current == null)
|
|
19
|
+
doc.setIn(path, sequence(doc, [value]));
|
|
20
|
+
else
|
|
21
|
+
throw new Error(`Cannot merge config: ${path.join(".")} must be a sequence`);
|
|
22
|
+
}
|
|
23
|
+
export async function mergeConfig(path) {
|
|
24
|
+
const source = await readFile(path, "utf8");
|
|
25
|
+
const doc = parseDocument(source);
|
|
26
|
+
if (doc.errors.length)
|
|
27
|
+
throw new Error(`Invalid YAML in ${path}: ${doc.errors[0].message}`);
|
|
28
|
+
if (!(doc.contents instanceof YAMLMap))
|
|
29
|
+
throw new Error(`Invalid YAML in ${path}: root must be a mapping`);
|
|
30
|
+
const context = doc.get("context");
|
|
31
|
+
if (context == null)
|
|
32
|
+
doc.set("context", CONTEXT_LINE);
|
|
33
|
+
else if (typeof context === "string" && !context.includes(CONTEXT_LINE))
|
|
34
|
+
doc.set("context", `${context.trimEnd()}\n${CONTEXT_LINE}${context.endsWith("\n") ? "\n" : ""}`);
|
|
35
|
+
else if (typeof context !== "string")
|
|
36
|
+
throw new Error("Cannot merge config: context must be a string");
|
|
37
|
+
addUnique(doc, ["rules", "proposal"], PROPOSAL_RULE);
|
|
38
|
+
addUnique(doc, ["rules", "tasks"], TASKS_RULE);
|
|
39
|
+
addUnique(doc, ["operations", "archive", "guidance"], ARCHIVE_GUIDANCE);
|
|
40
|
+
await writeFile(path, doc.toString({ lineWidth: 0 }), "utf8");
|
|
41
|
+
}
|
|
42
|
+
function removeFromSequence(doc, path, value) {
|
|
43
|
+
const current = doc.getIn(path, true);
|
|
44
|
+
if (current instanceof YAMLSeq)
|
|
45
|
+
current.items = current.items.filter(item => String(item.value) !== value);
|
|
46
|
+
}
|
|
47
|
+
function pruneEmpty(doc, path) {
|
|
48
|
+
const node = doc.getIn(path, true);
|
|
49
|
+
if ((node instanceof YAMLSeq || node instanceof YAMLMap) && node.items.length === 0)
|
|
50
|
+
doc.deleteIn(path);
|
|
51
|
+
}
|
|
52
|
+
export async function removeConfig(path) {
|
|
53
|
+
const doc = parseDocument(await readFile(path, "utf8"));
|
|
54
|
+
const context = doc.get("context");
|
|
55
|
+
if (typeof context === "string") {
|
|
56
|
+
const kept = context.split("\n").filter(line => line.trim() !== CONTEXT_LINE).join("\n").trimEnd();
|
|
57
|
+
// Restore the block scalar exactly as install found it, trailing newline included.
|
|
58
|
+
if (kept)
|
|
59
|
+
doc.set("context", context.endsWith("\n") ? `${kept}\n` : kept);
|
|
60
|
+
else
|
|
61
|
+
doc.delete("context");
|
|
62
|
+
}
|
|
63
|
+
removeFromSequence(doc, ["rules", "proposal"], PROPOSAL_RULE);
|
|
64
|
+
removeFromSequence(doc, ["rules", "tasks"], TASKS_RULE);
|
|
65
|
+
removeFromSequence(doc, ["operations", "archive", "guidance"], ARCHIVE_GUIDANCE);
|
|
66
|
+
// Drop containers left empty so removal restores the config install found, deepest first.
|
|
67
|
+
pruneEmpty(doc, ["rules", "proposal"]);
|
|
68
|
+
pruneEmpty(doc, ["rules", "tasks"]);
|
|
69
|
+
pruneEmpty(doc, ["rules"]);
|
|
70
|
+
pruneEmpty(doc, ["operations", "archive", "guidance"]);
|
|
71
|
+
pruneEmpty(doc, ["operations", "archive"]);
|
|
72
|
+
pruneEmpty(doc, ["operations"]);
|
|
73
|
+
await writeFile(path, doc.toString({ lineWidth: 0 }), "utf8");
|
|
74
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Consumer, type Manifest } from "./types.js";
|
|
2
|
+
export declare function isRoadmapEntry(file: string): boolean;
|
|
3
|
+
export declare function detectConsumers(root: string): Promise<Consumer[]>;
|
|
4
|
+
export declare function install(root: string, requested?: Consumer[]): Promise<Manifest>;
|
|
5
|
+
export declare function readManifest(root: string): Promise<Manifest | undefined>;
|
|
6
|
+
export declare function remove(root: string): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { mergeConfig, removeConfig } from "./config.js";
|
|
5
|
+
import { MANIFEST_NAME } from "./types.js";
|
|
6
|
+
const CONSUMER_DIRS = {
|
|
7
|
+
codex: ".codex/skills",
|
|
8
|
+
pi: ".pi/skills",
|
|
9
|
+
claude: ".claude/skills",
|
|
10
|
+
cursor: ".cursor/skills"
|
|
11
|
+
};
|
|
12
|
+
async function exists(path) { try {
|
|
13
|
+
await access(path);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
} }
|
|
19
|
+
const ROADMAP_FILE = "openspec/roadmap.md";
|
|
20
|
+
// Manifests are recorded with POSIX separators so they stay portable and so the
|
|
21
|
+
// roadmap guard in remove() matches on Windows, where relative() yields backslashes.
|
|
22
|
+
function manifestPath(root, target) {
|
|
23
|
+
return relative(root, target).split(sep).join("/");
|
|
24
|
+
}
|
|
25
|
+
// Exported for tests: the guard that keeps remove() from deleting the roadmap.
|
|
26
|
+
export function isRoadmapEntry(file) {
|
|
27
|
+
return file.split("\\").join("/") === ROADMAP_FILE;
|
|
28
|
+
}
|
|
29
|
+
function assetRoot() {
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const built = basename(here) === "src" && basename(dirname(here)) === "dist";
|
|
32
|
+
return built ? resolve(here, "../..") : resolve(here, "..");
|
|
33
|
+
}
|
|
34
|
+
async function packageVersion() {
|
|
35
|
+
const packageJson = JSON.parse(await readFile(join(assetRoot(), "package.json"), "utf8"));
|
|
36
|
+
if (typeof packageJson.version !== "string")
|
|
37
|
+
throw new Error("Package version is missing from package.json");
|
|
38
|
+
return packageJson.version;
|
|
39
|
+
}
|
|
40
|
+
export async function detectConsumers(root) {
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const [consumer, dir] of Object.entries(CONSUMER_DIRS)) {
|
|
43
|
+
if (await exists(join(root, dir.split("/")[0])))
|
|
44
|
+
found.push(consumer);
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
export async function install(root, requested) {
|
|
49
|
+
const openspec = join(root, "openspec");
|
|
50
|
+
const config = join(openspec, "config.yaml");
|
|
51
|
+
if (!await exists(config) || !await exists(join(openspec, "changes")) || !await exists(join(openspec, "specs"))) {
|
|
52
|
+
throw new Error("OpenSpec is not initialized (expected openspec/config.yaml, specs/, and changes/). Run `openspec init` first.");
|
|
53
|
+
}
|
|
54
|
+
const prior = await readManifest(root);
|
|
55
|
+
const detected = requested ?? await detectConsumers(root);
|
|
56
|
+
const consumers = [...new Set([...(prior?.consumers ?? []), ...detected])];
|
|
57
|
+
const files = new Set(prior?.files ?? []);
|
|
58
|
+
const roadmap = join(openspec, "roadmap.md");
|
|
59
|
+
if (!await exists(roadmap)) {
|
|
60
|
+
await copyFile(join(assetRoot(), "templates/roadmap.md"), roadmap);
|
|
61
|
+
}
|
|
62
|
+
files.add(manifestPath(root, roadmap));
|
|
63
|
+
for (const consumer of consumers)
|
|
64
|
+
for (const skill of ["openroad", "openroad-next"]) {
|
|
65
|
+
const destination = join(root, CONSUMER_DIRS[consumer], skill, "SKILL.md");
|
|
66
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
67
|
+
await copyFile(join(assetRoot(), "skills", skill, "SKILL.md"), destination);
|
|
68
|
+
files.add(manifestPath(root, destination));
|
|
69
|
+
}
|
|
70
|
+
await mergeConfig(config);
|
|
71
|
+
const manifest = { schemaVersion: 1, packageVersion: await packageVersion(), installedAt: prior?.installedAt ?? new Date().toISOString(), consumers, files: [...files].sort() };
|
|
72
|
+
await writeFile(join(root, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
|
|
73
|
+
return manifest;
|
|
74
|
+
}
|
|
75
|
+
export async function readManifest(root) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(await readFile(join(root, MANIFEST_NAME), "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function remove(root) {
|
|
84
|
+
const manifest = await readManifest(root);
|
|
85
|
+
if (!manifest)
|
|
86
|
+
throw new Error("OpenRoad is not installed (manifest not found)");
|
|
87
|
+
// toPosix also normalises manifests written by earlier versions on Windows.
|
|
88
|
+
for (const file of manifest.files)
|
|
89
|
+
if (!isRoadmapEntry(file))
|
|
90
|
+
await rm(join(root, file), { force: true });
|
|
91
|
+
await removeConfig(join(root, "openspec/config.yaml"));
|
|
92
|
+
await rm(join(root, MANIFEST_NAME), { force: true });
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RoadmapItem } from "./types.js";
|
|
2
|
+
export declare function parseRoadmap(source: string): {
|
|
3
|
+
items: RoadmapItem[];
|
|
4
|
+
errors: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function validateRoadmap(path: string): Promise<{
|
|
7
|
+
items: RoadmapItem[];
|
|
8
|
+
errors: string[];
|
|
9
|
+
}>;
|
|
10
|
+
export declare function eligibleReadyItems(items: RoadmapItem[]): RoadmapItem[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
const STATUSES = new Set(["planned", "ready", "active", "done", "cancelled"]);
|
|
3
|
+
const WORK_STATES = new Set(["available", "blocked", "paused"]);
|
|
4
|
+
export function parseRoadmap(source) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
const items = [];
|
|
7
|
+
const markdown = source.replace(/<!--[\s\S]*?-->/g, "");
|
|
8
|
+
const headings = [...markdown.matchAll(/^###\s+([A-Za-z][A-Za-z0-9_-]*-\d+)\s+[—-]\s+(.+)$/gm)];
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (let index = 0; index < headings.length; index++) {
|
|
11
|
+
const match = headings[index];
|
|
12
|
+
const id = match[1];
|
|
13
|
+
const bodyStart = (match.index ?? 0) + match[0].length;
|
|
14
|
+
const bodyEnd = headings[index + 1]?.index ?? markdown.length;
|
|
15
|
+
const body = markdown.slice(bodyStart, bodyEnd);
|
|
16
|
+
const fields = new Map();
|
|
17
|
+
for (const field of body.matchAll(/^\*\*([^*]+):\*\*[ \t]*(.*?)[ \t]*$/gm)) {
|
|
18
|
+
fields.set(field[1].trim().toLowerCase(), field[2].trim());
|
|
19
|
+
}
|
|
20
|
+
if (seen.has(id))
|
|
21
|
+
errors.push(`${id}: duplicate roadmap id`);
|
|
22
|
+
seen.add(id);
|
|
23
|
+
const status = fields.get("status") ?? "";
|
|
24
|
+
const workState = fields.get("work state") || undefined;
|
|
25
|
+
const priorityText = fields.get("priority") ?? "";
|
|
26
|
+
const priority = Number(priorityText);
|
|
27
|
+
if (!STATUSES.has(status))
|
|
28
|
+
errors.push(`${id}: invalid or missing Status`);
|
|
29
|
+
if (!Number.isInteger(priority) || priority < 0)
|
|
30
|
+
errors.push(`${id}: Priority must be a non-negative integer`);
|
|
31
|
+
if (status === "active" && !WORK_STATES.has(workState ?? "")) {
|
|
32
|
+
errors.push(`${id}: active items require Work state: available, blocked, or paused`);
|
|
33
|
+
}
|
|
34
|
+
if (status !== "active" && workState)
|
|
35
|
+
errors.push(`${id}: Work state is only valid for active items`);
|
|
36
|
+
if (status === "active" && !fields.get("change"))
|
|
37
|
+
errors.push(`${id}: active items require Change`);
|
|
38
|
+
items.push({
|
|
39
|
+
id,
|
|
40
|
+
title: match[2].trim(),
|
|
41
|
+
status: (STATUSES.has(status) ? status : "planned"),
|
|
42
|
+
workState: WORK_STATES.has(workState ?? "") ? workState : undefined,
|
|
43
|
+
priority: Number.isInteger(priority) ? priority : 0,
|
|
44
|
+
change: fields.get("change") || undefined,
|
|
45
|
+
dependsOn: (fields.get("depends on") ?? "").split(",").map(v => v.trim()).filter(Boolean),
|
|
46
|
+
blockedBy: fields.get("blocked by") || undefined
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (headings.length === 0)
|
|
50
|
+
errors.push("No roadmap items found (expected headings like `### RM-001 — Title`)");
|
|
51
|
+
const ids = new Set(items.map(item => item.id));
|
|
52
|
+
for (const item of items)
|
|
53
|
+
for (const dependency of item.dependsOn) {
|
|
54
|
+
if (!ids.has(dependency))
|
|
55
|
+
errors.push(`${item.id}: unknown dependency ${dependency}`);
|
|
56
|
+
if (dependency === item.id)
|
|
57
|
+
errors.push(`${item.id}: item cannot depend on itself`);
|
|
58
|
+
}
|
|
59
|
+
return { items, errors };
|
|
60
|
+
}
|
|
61
|
+
export async function validateRoadmap(path) {
|
|
62
|
+
return parseRoadmap(await readFile(path, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
export function eligibleReadyItems(items) {
|
|
65
|
+
const byId = new Map(items.map(item => [item.id, item]));
|
|
66
|
+
return items
|
|
67
|
+
.filter(item => item.status === "ready")
|
|
68
|
+
.filter(item => item.dependsOn.every(id => byId.get(id)?.status === "done"))
|
|
69
|
+
.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const MANIFEST_NAME = ".openroad.json";
|
|
2
|
+
export type Consumer = "codex" | "pi" | "claude" | "cursor";
|
|
3
|
+
export interface Manifest {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
packageVersion: string;
|
|
6
|
+
installedAt: string;
|
|
7
|
+
consumers: Consumer[];
|
|
8
|
+
files: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface RoadmapItem {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
status: "planned" | "ready" | "active" | "done" | "cancelled";
|
|
14
|
+
workState?: "available" | "blocked" | "paused";
|
|
15
|
+
priority: number;
|
|
16
|
+
change?: string;
|
|
17
|
+
dependsOn: string[];
|
|
18
|
+
blockedBy?: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MANIFEST_NAME = ".openroad.json";
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chriskealley/openroad",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A roadmap companion for OpenSpec projects",
|
|
5
|
+
"author": "Chris Kealley",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "openroad": "dist/src/cli.js" },
|
|
8
|
+
"files": ["dist/src", "templates", "skills", "README.md", "LICENSE"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"prepack": "npm run build",
|
|
12
|
+
"test": "npm run build && node --test \"dist/test/**/*.test.js\"",
|
|
13
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["openspec", "roadmap", "codex", "cli"],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/chriskealley/openroad.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/chriskealley/openroad/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/chriskealley/openroad#readme",
|
|
24
|
+
"engines": { "node": ">=22" },
|
|
25
|
+
"dependencies": { "yaml": "^2.8.1" },
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.3.0",
|
|
28
|
+
"typescript": "^5.9.2"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": { "access": "public" }
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openroad
|
|
3
|
+
description: Maintain the project OpenSpec roadmap and keep it synchronized with OpenSpec change lifecycle.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenRoad
|
|
7
|
+
|
|
8
|
+
Use this skill when the user asks to create, edit, review, or synchronize `openspec/roadmap.md`.
|
|
9
|
+
|
|
10
|
+
1. Read `openspec/roadmap.md`, `openspec/config.yaml`, and the relevant directories under `openspec/changes/`.
|
|
11
|
+
2. Treat `Status` as lifecycle: `planned`, `ready`, `active`, `done`, or `cancelled`.
|
|
12
|
+
3. Only active items have `Work state`: `available`, `blocked`, or `paused`.
|
|
13
|
+
4. Allow multiple active items. A blocked or paused active item does not prevent work on another eligible item.
|
|
14
|
+
5. Each active item must name its OpenSpec change in `Change`. Never reuse that change for another item.
|
|
15
|
+
6. Preserve stable roadmap IDs and explicit dependencies. Lower numeric `Priority` values rank first.
|
|
16
|
+
7. When an OpenSpec change is successfully archived, mark its linked item `done` and remove `Work state`. Do not infer successful archival merely from intent.
|
|
17
|
+
8. Run `openroad doctor` after edits and fix validation errors.
|
|
18
|
+
|
|
19
|
+
Do not edit OpenSpec-generated skills or fork an OpenSpec schema for roadmap coordination.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openroad-next
|
|
3
|
+
description: Select and start the highest-priority eligible ready roadmap item without disrupting concurrent active work.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenRoad next
|
|
7
|
+
|
|
8
|
+
Use this skill when the user asks what to work on next or asks to start the next roadmap change.
|
|
9
|
+
|
|
10
|
+
1. Read `openspec/roadmap.md`, validate it with `openroad doctor`, and inspect active OpenSpec changes.
|
|
11
|
+
2. Consider every item whose `Status` is `ready` and whose `Depends on` items are all `done`.
|
|
12
|
+
3. Exclude items already linked to a change and never duplicate an active change.
|
|
13
|
+
4. Select the eligible item with the lowest numeric `Priority`; break ties by roadmap ID.
|
|
14
|
+
5. Existing active items do not prevent selection. In particular, continue past active items whose `Work state` is `blocked` or `paused`.
|
|
15
|
+
6. If no item is eligible, report why and do not manufacture work.
|
|
16
|
+
7. Confirm the selected outcome and derive a short kebab-case change name. Use the installed OpenSpec CLI to create/propose the change according to the project's workflow.
|
|
17
|
+
8. Only after creation succeeds, update the roadmap item to `Status: active`, `Work state: available`, and set `Change` to the created name.
|
|
18
|
+
9. Preserve all unrelated roadmap content and run `openroad doctor`.
|
|
19
|
+
|
|
20
|
+
Never assume there can be only one active OpenSpec change. Do not patch OpenSpec-generated skills.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# OpenRoad
|
|
2
|
+
|
|
3
|
+
This file coordinates planned work with OpenSpec changes. `Status` is lifecycle; `Work state` only describes whether an active item can proceed. More than one item may be active.
|
|
4
|
+
|
|
5
|
+
## Status model
|
|
6
|
+
|
|
7
|
+
- `planned` — captured but not ready to start
|
|
8
|
+
- `ready` — eligible once dependencies are done
|
|
9
|
+
- `active` — has an OpenSpec change; requires `Work state`
|
|
10
|
+
- `done` — shipped and archived
|
|
11
|
+
- `cancelled` — intentionally abandoned
|
|
12
|
+
|
|
13
|
+
Active work states are `available`, `blocked`, or `paused`. Lower priority numbers run first.
|
|
14
|
+
|
|
15
|
+
## Items
|
|
16
|
+
|
|
17
|
+
### RM-001 — Replace with the first roadmap outcome
|
|
18
|
+
|
|
19
|
+
**Status:** planned
|
|
20
|
+
**Priority:** 100
|
|
21
|
+
**Depends on:**
|
|
22
|
+
|
|
23
|
+
Describe the outcome, scope, and acceptance signal here.
|
|
24
|
+
|
|
25
|
+
<!-- Active example:
|
|
26
|
+
### RM-002 — Example active outcome
|
|
27
|
+
|
|
28
|
+
**Status:** active
|
|
29
|
+
**Work state:** available
|
|
30
|
+
**Priority:** 200
|
|
31
|
+
**Change:** add-example-outcome
|
|
32
|
+
**Depends on:** RM-001
|
|
33
|
+
**Blocked by:**
|
|
34
|
+
-->
|