nikos 0.1.0 → 0.2.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 +45 -9
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +57 -1
- package/dist/commands/approve.d.ts +16 -0
- package/dist/commands/approve.js +120 -0
- package/dist/commands/ci.d.ts +18 -0
- package/dist/commands/ci.js +123 -0
- package/dist/commands/clear.d.ts +14 -0
- package/dist/commands/decide.d.ts +11 -0
- package/dist/commands/decide.js +3 -3
- package/dist/commands/decisions.d.ts +14 -0
- package/dist/commands/decisions.js +2 -2
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/export.d.ts +48 -0
- package/dist/commands/export.js +164 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +16 -13
- package/dist/commands/join.d.ts +17 -0
- package/dist/commands/join.js +91 -0
- package/dist/commands/map.d.ts +65 -0
- package/dist/commands/map.js +202 -25
- package/dist/commands/migrate.d.ts +10 -0
- package/dist/commands/migrate.js +105 -0
- package/dist/commands/next.d.ts +19 -0
- package/dist/commands/next.js +29 -5
- package/dist/commands/plan.d.ts +8 -0
- package/dist/commands/plan.js +9 -8
- package/dist/commands/release.d.ts +66 -0
- package/dist/commands/release.js +119 -17
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +26 -4
- package/dist/commands/start.d.ts +28 -0
- package/dist/commands/start.js +138 -0
- package/dist/commands/state.d.ts +27 -0
- package/dist/commands/state.js +40 -22
- package/dist/commands/step.d.ts +17 -0
- package/dist/commands/step.js +38 -2
- package/dist/commands/story.d.ts +21 -0
- package/dist/commands/story.js +7 -10
- package/dist/commands/suggest.d.ts +16 -0
- package/dist/commands/suggest.js +140 -0
- package/dist/commands/tracker.d.ts +60 -0
- package/dist/commands/tracker.js +185 -0
- package/dist/commands/validate.d.ts +15 -0
- package/dist/commands/validate.js +149 -14
- package/dist/commands/verify.d.ts +20 -0
- package/dist/commands/video.d.ts +33 -0
- package/dist/commands/video.js +4 -3
- package/dist/index.d.ts +20 -0
- package/dist/index.js +24 -0
- package/dist/lib/approvals.d.ts +29 -0
- package/dist/lib/approvals.js +28 -0
- package/dist/lib/bets.d.ts +21 -0
- package/dist/lib/bets.js +9 -2
- package/dist/lib/frontmatter.d.ts +5 -0
- package/dist/lib/project.d.ts +25 -0
- package/dist/lib/project.js +25 -7
- package/dist/lib/settings.d.ts +29 -0
- package/dist/lib/settings.js +110 -0
- package/dist/lib/state-files.d.ts +44 -0
- package/dist/lib/state-files.js +102 -0
- package/dist/lib/store.d.ts +44 -0
- package/dist/lib/store.js +97 -0
- package/dist/lint/constitution.d.ts +5 -0
- package/dist/lint/gherkin.d.ts +12 -0
- package/dist/lint/gherkin.js +2 -2
- package/dist/lint/portable.d.ts +10 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +239 -0
- package/dist/planning/bets.d.ts +31 -0
- package/dist/planning/cli.d.ts +8 -0
- package/dist/planning/cli.js +17 -1
- package/dist/planning/delivery.d.ts +12 -0
- package/dist/planning/delivery.js +8 -5
- package/dist/planning/krs.d.ts +28 -0
- package/dist/planning/model.d.ts +55 -0
- package/dist/planning/model.js +10 -10
- package/dist/planning/rollout.d.ts +72 -0
- package/dist/planning/rollout.js +206 -0
- package/dist/planning/server.d.ts +113 -0
- package/dist/planning/server.js +9 -6
- package/dist/planning/ui.d.ts +7 -0
- package/dist/planning/ui.js +26 -1
- package/dist/planning/upstream.d.ts +73 -0
- package/dist/planning/upstream.js +78 -20
- package/dist/verify/detect.d.ts +3 -0
- package/docs/format.md +112 -2
- package/docs/gherkin.md +3 -0
- package/docs/install.md +39 -0
- package/docs/library.md +53 -0
- package/docs/planning.md +51 -5
- package/docs/settings.md +112 -0
- package/package.json +12 -2
- package/schemas/approvals.schema.json +27 -0
- package/schemas/planning/kr.schema.json +1 -1
- package/schemas/settings.schema.json +40 -0
- package/schemas/state.schema.json +6 -2
- package/schemas/storymap.schema.json +13 -0
- package/skills/nikos-mode/SKILL.md +22 -1
- package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
- package/skills/nikos-mode/playbooks/company.md +16 -0
- package/skills/nikos-mode/playbooks/index.md +4 -0
- package/skills/nikos-mode/playbooks/map-existing.md +14 -0
- package/skills/nikos-mode/playbooks/start.md +12 -0
- package/skills/nikos-mode/playbooks/tracker.md +14 -0
- package/skills/nikos-mode/trackers/github.md +18 -0
- package/skills/nikos-mode/trackers/gitlab.md +17 -0
- package/skills/nikos-mode/trackers/jira.md +19 -0
- package/skills/nikos-mode/trackers/linear.md +17 -0
- package/skills/verify/SKILL.md +9 -0
package/README.md
CHANGED
|
@@ -38,10 +38,29 @@ scenario, with every command and the output you should see.
|
|
|
38
38
|
| Claude Code | `/plugin install nikos --marketplace nikovijay/NikOS` |
|
|
39
39
|
| Codex | `codex plugin marketplace add nikovijay/NikOS` |
|
|
40
40
|
| Cursor | Add `https://github.com/nikovijay/NikOS` as a plugin marketplace |
|
|
41
|
+
| Claude Desktop, or any app that runs MCP servers | `npx -y nikos mcp --dir <repo>` as a local MCP server: see [docs/install.md](docs/install.md#any-app-that-runs-mcp-servers) |
|
|
41
42
|
|
|
42
|
-
Then
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
Then talk to your agent: the `nikos-mode` skill takes it from there. The
|
|
44
|
+
skills run the `nikos` command through `npx`, so there's nothing else to
|
|
45
|
+
install. More in [docs/install.md](docs/install.md).
|
|
46
|
+
|
|
47
|
+
## Talk to it
|
|
48
|
+
|
|
49
|
+
You don't need to learn the commands. Your agent runs them and, after each
|
|
50
|
+
request, says where things stand and offers a short menu of what to do next.
|
|
51
|
+
Things you can say:
|
|
52
|
+
|
|
53
|
+
- "Set up nikOS here", or "Map what this repo already does"
|
|
54
|
+
- "Set nikOS up for our company" (a company plan every team joins)
|
|
55
|
+
- "What should we build next?"
|
|
56
|
+
- "Add a story: people can share a list with one other person"
|
|
57
|
+
- "Build HT-3"
|
|
58
|
+
- "Is release 1 ready to ship?"
|
|
59
|
+
- "Show me the story map"
|
|
60
|
+
|
|
61
|
+
When a story is done, your agent asks "Do you want to approve HT-3: Reorder
|
|
62
|
+
tasks?". Your answer is the sign-off. It's recorded in
|
|
63
|
+
`intent/approvals.yaml`, and your agent never approves on its own.
|
|
45
64
|
|
|
46
65
|
## The loop
|
|
47
66
|
|
|
@@ -54,8 +73,9 @@ npx -y nikos verify TC-1 # run the story: it fails until it's built
|
|
|
54
73
|
npx -y nikos state set TC-1 done # refused unless its scenarios pass
|
|
55
74
|
```
|
|
56
75
|
|
|
57
|
-
With an agent, you only
|
|
58
|
-
it"); the playbooks run these steps and stop at each
|
|
76
|
+
That's what runs underneath. With an agent, you only make the request ("add
|
|
77
|
+
a story for X and build it"); the playbooks run these steps and stop at each
|
|
78
|
+
point that needs you.
|
|
59
79
|
|
|
60
80
|
## What lives in your repo
|
|
61
81
|
|
|
@@ -63,7 +83,9 @@ it"); the playbooks run these steps and stop at each point that needs you.
|
|
|
63
83
|
intent/
|
|
64
84
|
├── constitution.md who it's for, the problem, what proves it works
|
|
65
85
|
├── storymap.yaml activities and steps, releases, and the order to build stories in
|
|
66
|
-
├── state.yaml each story's status: todo, in_progress, needs_rework, done
|
|
86
|
+
├── state.yaml each story's status: todo, in_progress, needs_rework, done, cancelled
|
|
87
|
+
├── approvals.yaml people's sign-offs on done stories (optional)
|
|
88
|
+
│ (or state/<ID>.yaml and approvals/<ID>.yaml, one per story)
|
|
67
89
|
├── decisions.md why things are the way they are, append-only
|
|
68
90
|
├── features/ one .feature file per story; its scenarios are the criteria
|
|
69
91
|
│ └── <activity>/<story>.feature
|
|
@@ -71,28 +93,42 @@ intent/
|
|
|
71
93
|
```
|
|
72
94
|
|
|
73
95
|
Every file is plain text you can read, review and diff. The full format:
|
|
74
|
-
[docs/format.md](docs/format.md).
|
|
96
|
+
[docs/format.md](docs/format.md). Tools that work with these files without a
|
|
97
|
+
shell can use nikOS as a library: [docs/library.md](docs/library.md). A worked example:
|
|
75
98
|
[examples/hello-todo](examples/hello-todo/intent).
|
|
76
99
|
|
|
77
100
|
## Commands
|
|
78
101
|
|
|
79
102
|
| Command | What it does |
|
|
80
103
|
| --- | --- |
|
|
104
|
+
| `nikos start` | Set this repo up in one go: `intent/`, a scenario runner for its stack and CI for its host |
|
|
81
105
|
| `nikos init` | Create `intent/` in this repo |
|
|
106
|
+
| `nikos company init` | Write a company plan repo that departments and teams join ([docs/settings.md](docs/settings.md)) |
|
|
107
|
+
| `nikos activity add "<title>"` | Add an activity to the story map |
|
|
82
108
|
| `nikos validate` | Check `intent/` is well formed |
|
|
83
109
|
| `nikos story add "<title>"` | Add a story to the map, state and a starter feature |
|
|
84
110
|
| `nikos step add "<title>"` | Add a step under an activity |
|
|
85
|
-
| `nikos release add\|rename\|move\|reorder` | Define releases and move stories between them, keeping feature tags in step |
|
|
111
|
+
| `nikos release add\|rename\|move\|reorder\|remove` | Define releases and move stories between them, keeping feature tags in step |
|
|
112
|
+
| `nikos release check <n>` | Say whether a release is ready to ship, and what's in the way |
|
|
86
113
|
| `nikos clear <id>` | Check a story is ready to build |
|
|
87
114
|
| `nikos plan new <id>` | Start the next version of a story's plan |
|
|
88
115
|
| `nikos verify setup` | Add a Cucumber runner for this repo's stack |
|
|
116
|
+
| `nikos ci setup` | Run validate and every story's scenarios on each PR (GitHub Actions) or merge request (GitLab CI) |
|
|
89
117
|
| `nikos verify [id]` | Run a story's scenarios, or all of them |
|
|
90
|
-
| `nikos state set <id> <status>` | Move a story; done only if its scenarios pass |
|
|
118
|
+
| `nikos state set <id> <status>` | Move a story; done only if its scenarios pass; cancelled needs a reason |
|
|
119
|
+
| `nikos approve <id>...` | Sign off done stories, in your terminal or (`--via chat`) by answering your agent (writes `intent/approvals.yaml`) |
|
|
91
120
|
| `nikos next` | Print the next story to build |
|
|
121
|
+
| `nikos migrate per-story` | Give each story its own state and approval file, so parallel branches never conflict |
|
|
122
|
+
| `nikos suggest` | Where things stand, and a short menu of what to do next |
|
|
123
|
+
| `nikos join <repo>` | Plan against a company or department plan and inherit its settings ([docs/settings.md](docs/settings.md)) |
|
|
124
|
+
| `nikos settings` | The settings that apply here, and where each comes from |
|
|
125
|
+
| `nikos doctor` | Check the repo is set up well, and what to do next |
|
|
126
|
+
| `nikos tracker link\|record\|sync\|import` | Keep the story map and Jira, Linear, GitHub or GitLab issues in step, through the tracker's MCP |
|
|
92
127
|
| `nikos show <id>` | Summarise a story: feature, status, plan, bets, decisions |
|
|
93
128
|
| `nikos decide "<title>"` | Append a decision to `decisions.md` |
|
|
94
129
|
| `nikos decisions "<q>"` | Quote the decisions about a topic or story |
|
|
95
130
|
| `nikos map` | Write a read-only story map page |
|
|
131
|
+
| `nikos export --format prd\|linear\|jira` | Write the story map as a PRD or a Linear or Jira import |
|
|
96
132
|
|
|
97
133
|
## Links
|
|
98
134
|
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { runValidate } from "./commands/validate.js";
|
|
3
4
|
import { runPlan } from "./commands/plan.js";
|
|
4
5
|
import { runInit } from "./commands/init.js";
|
|
5
6
|
import { runClear } from "./commands/clear.js";
|
|
6
7
|
import { runVerify } from "./commands/verify.js";
|
|
8
|
+
import { runCi } from "./commands/ci.js";
|
|
7
9
|
import { runNext } from "./commands/next.js";
|
|
8
10
|
import { runState } from "./commands/state.js";
|
|
11
|
+
import { runApprove } from "./commands/approve.js";
|
|
12
|
+
import { runSuggest } from "./commands/suggest.js";
|
|
13
|
+
import { runMcp } from "./mcp/server.js";
|
|
14
|
+
import { runJoin, runSettings } from "./commands/join.js";
|
|
15
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
16
|
+
import { runMigrate } from "./commands/migrate.js";
|
|
17
|
+
import { runTracker } from "./commands/tracker.js";
|
|
18
|
+
import { runCompany, runStart } from "./commands/start.js";
|
|
9
19
|
import { runDecide } from "./commands/decide.js";
|
|
10
20
|
import { runShow } from "./commands/show.js";
|
|
11
21
|
import { runDecisions } from "./commands/decisions.js";
|
|
12
22
|
import { runStory } from "./commands/story.js";
|
|
13
|
-
import { runStep } from "./commands/step.js";
|
|
23
|
+
import { runActivity, runStep } from "./commands/step.js";
|
|
14
24
|
import { runRelease } from "./commands/release.js";
|
|
15
25
|
import { runVideo } from "./commands/video.js";
|
|
16
26
|
import { runMap } from "./commands/map.js";
|
|
27
|
+
import { runExport } from "./commands/export.js";
|
|
17
28
|
import { runPlanning } from "./planning/cli.js";
|
|
18
29
|
const commands = {
|
|
19
30
|
init: runInit,
|
|
@@ -21,16 +32,29 @@ const commands = {
|
|
|
21
32
|
plan: runPlan,
|
|
22
33
|
clear: runClear,
|
|
23
34
|
verify: runVerify,
|
|
35
|
+
ci: runCi,
|
|
24
36
|
next: runNext,
|
|
25
37
|
state: runState,
|
|
38
|
+
approve: runApprove,
|
|
39
|
+
suggest: runSuggest,
|
|
40
|
+
mcp: runMcp,
|
|
41
|
+
join: runJoin,
|
|
42
|
+
settings: runSettings,
|
|
43
|
+
doctor: runDoctor,
|
|
44
|
+
migrate: runMigrate,
|
|
45
|
+
tracker: runTracker,
|
|
46
|
+
start: runStart,
|
|
47
|
+
company: runCompany,
|
|
26
48
|
decide: runDecide,
|
|
27
49
|
show: runShow,
|
|
28
50
|
decisions: runDecisions,
|
|
29
51
|
story: runStory,
|
|
30
52
|
step: runStep,
|
|
53
|
+
activity: runActivity,
|
|
31
54
|
release: runRelease,
|
|
32
55
|
video: runVideo,
|
|
33
56
|
map: runMap,
|
|
57
|
+
export: runExport,
|
|
34
58
|
planning: runPlanning,
|
|
35
59
|
};
|
|
36
60
|
const help = `nikos — spec-driven development for AI coding agents
|
|
@@ -38,31 +62,59 @@ const help = `nikos — spec-driven development for AI coding agents
|
|
|
38
62
|
Usage: nikos <command> [options]
|
|
39
63
|
|
|
40
64
|
Commands:
|
|
65
|
+
start Set this repo up in one go: intent/, a scenario runner for its stack
|
|
66
|
+
and CI for its host, each only if it's missing (--name, --key)
|
|
41
67
|
init Create intent/ in this repo (--name, --key)
|
|
42
68
|
validate [dir] Check the intent/ folder is well formed
|
|
43
69
|
story add "<title>" Add a story to the map, state and a starter feature
|
|
44
70
|
(--activity, --step, --release, --priority)
|
|
71
|
+
activity add "<title>" Add an activity to the story map (--id)
|
|
45
72
|
step add "<title>" Add a step under an activity (--activity, --id)
|
|
46
73
|
release add "<title>" Add the next release (--goal)
|
|
47
74
|
release rename <n> Change a release's title or goal (--goal)
|
|
48
75
|
release move <id> <n> Move a story to another release, retagging its feature
|
|
49
76
|
release reorder <n> <pos> Move a release and renumber them all
|
|
77
|
+
release remove <n> Remove an empty release and renumber the rest
|
|
78
|
+
release check <n> Is a release ready to ship? (--run reruns its scenarios)
|
|
50
79
|
plan new <id> Start the next version of a story's plan
|
|
51
80
|
clear <id> Check a story is Lean and Executable before building
|
|
52
81
|
verify setup Set up a Cucumber runner for this repo's stack
|
|
53
82
|
verify [id] Run a story's scenarios (or all of them)
|
|
83
|
+
ci setup Run validate and every story's scenarios on each PR or merge
|
|
84
|
+
request (GitHub Actions or GitLab CI; --github, --gitlab)
|
|
54
85
|
show <id> Summarise a story: feature, status, plan, bets, decisions
|
|
55
86
|
decisions "<q>" Quote the decisions.md entries that mention a topic or story
|
|
56
87
|
next Print the next story to build
|
|
57
88
|
state set <id> <status> Move a story; done only if its scenarios pass
|
|
89
|
+
(cancelled needs --reason)
|
|
90
|
+
approve <id>... Sign off done stories: in your terminal, or --via chat when
|
|
91
|
+
you've answered an agent's "Do you want to approve …?"
|
|
92
|
+
suggest What to do next: where things stand and a short menu (--json, --brief)
|
|
93
|
+
company init Write a company plan repo that departments and teams join
|
|
94
|
+
(--name, --period, --departments, --repo, --host)
|
|
95
|
+
join <repo> Plan against a company or department plan, and inherit its settings
|
|
96
|
+
(--host gitlab.com, --url, --ref)
|
|
97
|
+
settings [set <key> <value>] See the settings that apply here and where each comes
|
|
98
|
+
from, or change one this repo is allowed to
|
|
99
|
+
doctor Check this repo is set up well, and say what to do next (--offline)
|
|
100
|
+
migrate per-story Give each story its own state and approval file, so parallel
|
|
101
|
+
branches never conflict (migrate single-file goes back)
|
|
102
|
+
tracker link|record|sync|import Keep the story map and a Jira, Linear, GitHub or GitLab
|
|
103
|
+
tracker in step; the agent reads tickets through the tracker's MCP
|
|
104
|
+
mcp Run nikOS as a local MCP server for Claude Desktop, Cursor, Codex and
|
|
105
|
+
other apps (--dir <repo>)
|
|
58
106
|
decide "<title>" Append a decision to decisions.md
|
|
59
107
|
(--why, --story, --category, --supersedes)
|
|
60
108
|
video Draft a video brief and storyboard from the done stories
|
|
61
109
|
(--out, default marketing/storymap-video)
|
|
62
110
|
map Write a read-only story map page (--out, default storymap.html)
|
|
111
|
+
export --format <prd|linear|jira> The story map as a PRD or tracker import
|
|
112
|
+
(--release, --out, --project-key)
|
|
63
113
|
planning <cmd> Bets, key results and the planning dashboard (nikos-planning pack;
|
|
64
114
|
run \`nikos planning help\`)
|
|
65
115
|
|
|
116
|
+
--version Print the installed version
|
|
117
|
+
|
|
66
118
|
Docs: https://nikos.tools`;
|
|
67
119
|
async function main(argv) {
|
|
68
120
|
const [cmd, ...rest] = argv;
|
|
@@ -70,6 +122,10 @@ async function main(argv) {
|
|
|
70
122
|
console.log(help);
|
|
71
123
|
return 0;
|
|
72
124
|
}
|
|
125
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "version") {
|
|
126
|
+
console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
73
129
|
const run = commands[cmd];
|
|
74
130
|
if (!run) {
|
|
75
131
|
console.error(`Unknown command '${cmd}'.\n\n${help}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Who is approving: git's user.name, or the login name when git has none. */
|
|
2
|
+
export declare function approver(dir: string): string;
|
|
3
|
+
export interface ApproveResult {
|
|
4
|
+
story: string;
|
|
5
|
+
by: string;
|
|
6
|
+
on: string;
|
|
7
|
+
}
|
|
8
|
+
/** Check a story can be approved, rerun its scenarios, then record the approval. The terminal check lives in runApprove. */
|
|
9
|
+
export type Via = "terminal" | "chat" | "app" | "tracker";
|
|
10
|
+
export declare function approve(story: string, opts: {
|
|
11
|
+
dir?: string;
|
|
12
|
+
by: string;
|
|
13
|
+
date?: string;
|
|
14
|
+
via?: Via;
|
|
15
|
+
}): ApproveResult;
|
|
16
|
+
export declare function runApprove(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// `nikos approve`: a person's sign-off on a done story, written to intent/approvals.yaml
|
|
2
|
+
// (or intent/approvals/<ID>.yaml). Two
|
|
3
|
+
// ways in, recorded as `via`:
|
|
4
|
+
// - terminal: the person runs it and types the story id back. A coding agent runs commands
|
|
5
|
+
// without a terminal, so it can't take this route.
|
|
6
|
+
// - chat: the agent asked "Do you want to approve …?" and the person answered Approve. The
|
|
7
|
+
// playbooks only allow it after that answer; `via: chat` shows it was given that way.
|
|
8
|
+
// - app: nikos mcp asked the person through their app (MCP elicitation), so the model never
|
|
9
|
+
// saw the question or gave the answer.
|
|
10
|
+
// - tracker: a person moved the story's ticket to a status mapped to approved (nikos tracker
|
|
11
|
+
// record), when tracker.approvals is on.
|
|
12
|
+
// A repo can go further and protect approvals.yaml with CODEOWNERS, so every approval also
|
|
13
|
+
// needs a review on GitHub or GitLab.
|
|
14
|
+
// The approval holds while the story stays done, since the same done, with the same feature.
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { userInfo } from "node:os";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { approvalLapse, doneMark, loadApprovals } from "../lib/approvals.js";
|
|
20
|
+
import { noEntry, storyState, writeApproval } from "../lib/state-files.js";
|
|
21
|
+
import { resolveSettings } from "../lib/settings.js";
|
|
22
|
+
import { STORY_ID, findIntentDir, read, specHash } from "../lib/project.js";
|
|
23
|
+
import { featureFor } from "./clear.js";
|
|
24
|
+
import { StateError, failingScenarios } from "./state.js";
|
|
25
|
+
import { verify } from "./verify.js";
|
|
26
|
+
/** Who is approving: git's user.name, or the login name when git has none. */
|
|
27
|
+
export function approver(dir) {
|
|
28
|
+
const git = spawnSync("git", ["config", "user.name"], { cwd: dir, encoding: "utf8" });
|
|
29
|
+
return git.stdout?.trim() || userInfo().username;
|
|
30
|
+
}
|
|
31
|
+
export function approve(story, opts) {
|
|
32
|
+
if (!STORY_ID.test(story))
|
|
33
|
+
throw new StateError(`'${story}' is not a story id (expected e.g. HT-1)`);
|
|
34
|
+
const intentDir = findIntentDir(opts.dir ?? process.cwd());
|
|
35
|
+
const settings = resolveSettings(dirname(intentDir));
|
|
36
|
+
const policy = settings["approval.policy"];
|
|
37
|
+
if (policy.value === "off")
|
|
38
|
+
throw new StateError(`Approvals are off here (approval.policy, set by ${policy.from})`);
|
|
39
|
+
const via = opts.via ?? "terminal";
|
|
40
|
+
const routes = settings["approval.routes"];
|
|
41
|
+
// A tracker approval counts when tracker.approvals is on; the other routes by approval.routes.
|
|
42
|
+
if (via === "tracker" && !settings["tracker.approvals"].value)
|
|
43
|
+
throw new StateError("Tracker approvals don't count here (tracker.approvals is off)");
|
|
44
|
+
if (via !== "tracker" && !routes.value.includes(via)) {
|
|
45
|
+
throw new StateError(`Approvals ${via === "app" ? "through the app" : via === "chat" ? "in chat" : "in a terminal"} don't count here: approval.routes is ${routes.value.join(", ")} (set by ${routes.from})`);
|
|
46
|
+
}
|
|
47
|
+
const entry = storyState(intentDir, story);
|
|
48
|
+
if (!entry)
|
|
49
|
+
throw new StateError(noEntry(intentDir, story));
|
|
50
|
+
if (entry.status !== "done")
|
|
51
|
+
throw new StateError(`${story} is ${entry.status}: only a done story can be approved`);
|
|
52
|
+
const feature = featureFor(story, dirname(intentDir));
|
|
53
|
+
const hash = feature ? specHash(read(feature)) : undefined;
|
|
54
|
+
if (!hash)
|
|
55
|
+
throw new StateError(`No feature file is tagged @${story}`);
|
|
56
|
+
if (entry.spec_hash && entry.spec_hash !== hash) {
|
|
57
|
+
throw new StateError(`${story}'s feature changed after it was done. Move it to needs_rework and through done again before approving`);
|
|
58
|
+
}
|
|
59
|
+
const existing = loadApprovals(intentDir)[story];
|
|
60
|
+
if (existing && !approvalLapse(existing, entry, hash))
|
|
61
|
+
throw new StateError(`${story} is already approved (by ${existing.by} on ${existing.on})`);
|
|
62
|
+
let run;
|
|
63
|
+
try {
|
|
64
|
+
run = verify(dirname(intentDir), story);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new StateError(`${story} isn't approved: its scenarios couldn't run. ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (run.status !== 0) {
|
|
70
|
+
const failing = failingScenarios(run.output);
|
|
71
|
+
throw new StateError(`${story} isn't approved: its scenarios don't all pass${failing.length ? `:\n${failing.map((f) => ` ✗ ${f}`).join("\n")}` : ". See the runner output."}`);
|
|
72
|
+
}
|
|
73
|
+
const on = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
74
|
+
writeApproval(intentDir, story, { by: opts.by, on, via, spec_hash: hash, done_at: doneMark(entry) });
|
|
75
|
+
return { story, by: opts.by, on };
|
|
76
|
+
}
|
|
77
|
+
export async function runApprove(args) {
|
|
78
|
+
const flag = (n) => {
|
|
79
|
+
const i = args.indexOf(`--${n}`);
|
|
80
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
81
|
+
};
|
|
82
|
+
const stories = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--")));
|
|
83
|
+
const dir = flag("dir") ?? process.cwd();
|
|
84
|
+
const via = flag("via") ?? "terminal";
|
|
85
|
+
if (!stories.length || (via !== "terminal" && via !== "chat")) {
|
|
86
|
+
console.error("Usage: nikos approve <story-id>... [--via chat] [--dir <path>]");
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const by = approver(dir);
|
|
90
|
+
if (via === "terminal") {
|
|
91
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
92
|
+
console.error("✗ nikos approve only runs in an interactive terminal. In a chat with an agent, the agent asks \"Do you want to approve …?\" and records your answer with --via chat.");
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
96
|
+
try {
|
|
97
|
+
const want = stories.join(" ");
|
|
98
|
+
const typed = (await rl.question(`Approving ${want} as ${by}. Scenarios run again first.\nType ${want} to approve: `)).trim();
|
|
99
|
+
if (typed !== want) {
|
|
100
|
+
console.error("✗ Not approved: that wasn't the story id.");
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rl.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let failed = 0;
|
|
109
|
+
for (const story of stories) {
|
|
110
|
+
try {
|
|
111
|
+
approve(story, { dir, by, via });
|
|
112
|
+
console.log(`${story}: approved by ${by}${via === "chat" ? " in chat" : ""} (intent/approvals.yaml)`);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
console.error(`✗ ${err.message}`);
|
|
116
|
+
failed++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return failed ? 1 : 0;
|
|
120
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Stack } from "../verify/detect.js";
|
|
2
|
+
export type Host = "github" | "gitlab";
|
|
3
|
+
/** github or gitlab, from the origin remote's url; undefined when it's neither or there's no remote. */
|
|
4
|
+
export declare function detectHost(root: string): Host | undefined;
|
|
5
|
+
export declare function githubWorkflow(stack: Stack, root: string): string;
|
|
6
|
+
export declare function gitlabJob(stack: Stack, root: string): string;
|
|
7
|
+
export interface CiResult {
|
|
8
|
+
host: Host;
|
|
9
|
+
created: string[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
/** Something the person (or their agent) still has to do by hand. */
|
|
12
|
+
todo?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Write the pipeline for the repo's host and stack. Never overwrites a file. */
|
|
15
|
+
export declare function setupCi(root: string, opts?: {
|
|
16
|
+
host?: Host;
|
|
17
|
+
}): CiResult;
|
|
18
|
+
export declare function runCi(args: string[]): number;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `nikos ci setup`: run nikOS's checks on every pull request (GitHub) or merge request (GitLab).
|
|
2
|
+
// The pipeline runs `nikos validate` and every story's scenarios with `nikos verify`, using the
|
|
3
|
+
// nikos version that wrote it, so a red pipeline means the specs or the code broke, not nikos.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { detectStack } from "../verify/detect.js";
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const version = () => {
|
|
11
|
+
const pkg = [join(here, "../../package.json"), join(here, "../package.json")].find((p) => existsSync(p));
|
|
12
|
+
return JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
13
|
+
};
|
|
14
|
+
/** github or gitlab, from the origin remote's url; undefined when it's neither or there's no remote. */
|
|
15
|
+
export function detectHost(root) {
|
|
16
|
+
const url = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
17
|
+
if (/github\.com[:/]/.test(url))
|
|
18
|
+
return "github";
|
|
19
|
+
if (/gitlab/i.test(url))
|
|
20
|
+
return "gitlab";
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/** The shell steps that install the runner and run the checks, for a stack. */
|
|
24
|
+
function steps(stack, root, nikos) {
|
|
25
|
+
const install = stack === "python"
|
|
26
|
+
? [existsSync(join(root, "requirements.txt")) ? "pip install -r requirements.txt" : "", "pip install behave"].filter(Boolean)
|
|
27
|
+
: [existsSync(join(root, "package-lock.json")) ? "npm ci" : "npm install"];
|
|
28
|
+
return [...install, `npx -y ${nikos} validate`, `npx -y ${nikos} verify`];
|
|
29
|
+
}
|
|
30
|
+
export function githubWorkflow(stack, root) {
|
|
31
|
+
const nikos = `nikos@${version()}`;
|
|
32
|
+
const python = stack === "python" ? ` - uses: actions/setup-python@v5\n with:\n python-version: "3.12"\n` : "";
|
|
33
|
+
return `# Written by nikos ci setup: checks the specs in intent/ and runs every story's scenarios.
|
|
34
|
+
name: nikos
|
|
35
|
+
on:
|
|
36
|
+
pull_request:
|
|
37
|
+
push:
|
|
38
|
+
branches: [main]
|
|
39
|
+
jobs:
|
|
40
|
+
nikos:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
- uses: actions/setup-node@v4
|
|
45
|
+
with:
|
|
46
|
+
node-version: 22
|
|
47
|
+
${python}${steps(stack, root, nikos).map((s) => ` - run: ${s}`).join("\n")}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
export function gitlabJob(stack, root) {
|
|
51
|
+
const nikos = `nikos@${version()}`;
|
|
52
|
+
// Python stories need node for nikos itself, so that image installs it.
|
|
53
|
+
const image = stack === "python" ? "python:3.12" : "node:22";
|
|
54
|
+
const setup = stack === "python" ? ["apt-get update -qq && apt-get install -y -qq nodejs npm"] : [];
|
|
55
|
+
return `# Written by nikos ci setup: checks the specs in intent/ and runs every story's scenarios.
|
|
56
|
+
nikos:
|
|
57
|
+
image: ${image}
|
|
58
|
+
rules:
|
|
59
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
60
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
61
|
+
script:
|
|
62
|
+
${[...setup, ...steps(stack, root, nikos)].map((s) => ` - ${s}`).join("\n")}
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
const GITLAB_JOB = ".gitlab/ci/nikos.yml";
|
|
66
|
+
const INCLUDE = `include:\n - local: ${GITLAB_JOB}\n`;
|
|
67
|
+
/** Write the pipeline for the repo's host and stack. Never overwrites a file. */
|
|
68
|
+
export function setupCi(root, opts = {}) {
|
|
69
|
+
const host = opts.host ?? detectHost(root);
|
|
70
|
+
if (!host)
|
|
71
|
+
throw new Error("Couldn't tell where this repo is hosted from its origin remote. Say which: --github or --gitlab");
|
|
72
|
+
const stack = detectStack(root);
|
|
73
|
+
if (!stack)
|
|
74
|
+
throw new Error("Couldn't tell the stack: no package.json, pyproject.toml or requirements.txt. Run nikos verify setup first.");
|
|
75
|
+
const created = [];
|
|
76
|
+
const skipped = [];
|
|
77
|
+
const write = (rel, text) => {
|
|
78
|
+
const path = join(root, rel);
|
|
79
|
+
if (existsSync(path))
|
|
80
|
+
return void skipped.push(rel);
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
+
writeFileSync(path, text);
|
|
83
|
+
created.push(rel);
|
|
84
|
+
};
|
|
85
|
+
if (host === "github") {
|
|
86
|
+
write(".github/workflows/nikos.yml", githubWorkflow(stack, root));
|
|
87
|
+
return { host, created, skipped };
|
|
88
|
+
}
|
|
89
|
+
// GitLab reads one .gitlab-ci.yml, so the job lives in its own file and is included from it.
|
|
90
|
+
write(GITLAB_JOB, gitlabJob(stack, root));
|
|
91
|
+
const main = join(root, ".gitlab-ci.yml");
|
|
92
|
+
if (!existsSync(main)) {
|
|
93
|
+
write(".gitlab-ci.yml", INCLUDE);
|
|
94
|
+
return { host, created, skipped };
|
|
95
|
+
}
|
|
96
|
+
const included = readFileSync(main, "utf8").includes(GITLAB_JOB);
|
|
97
|
+
return { host, created, skipped, todo: included ? undefined : `Add this to .gitlab-ci.yml:\n${INCLUDE}` };
|
|
98
|
+
}
|
|
99
|
+
export function runCi(args) {
|
|
100
|
+
const [sub, ...rest] = args;
|
|
101
|
+
if (sub !== "setup") {
|
|
102
|
+
console.error("Usage: nikos ci setup [--github | --gitlab] [--dir <path>]");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const i = rest.indexOf("--dir");
|
|
106
|
+
const root = i >= 0 ? rest[i + 1] : process.cwd();
|
|
107
|
+
const host = rest.includes("--github") ? "github" : rest.includes("--gitlab") ? "gitlab" : undefined;
|
|
108
|
+
try {
|
|
109
|
+
const r = setupCi(root, { host });
|
|
110
|
+
if (r.created.length)
|
|
111
|
+
console.log(`Created:\n${r.created.map((f) => ` + ${f}`).join("\n")}`);
|
|
112
|
+
if (r.skipped.length)
|
|
113
|
+
console.log(`Skipped (already there):\n${r.skipped.map((f) => ` = ${f}`).join("\n")}`);
|
|
114
|
+
if (r.todo)
|
|
115
|
+
console.log(`\n${r.todo}`);
|
|
116
|
+
console.log(`\nEvery ${r.host === "github" ? "pull request" : "merge request"} now runs nikos validate and every story's scenarios.`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error(`✗ ${err.message}`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type CheckName = "Clear" | "Lean" | "Executable" | "Accurate" | "Responsible";
|
|
2
|
+
export interface CheckResult {
|
|
3
|
+
check: CheckName;
|
|
4
|
+
/** `agent` means the check needs judgement, so the CLEAR skill decides it. */
|
|
5
|
+
status: "pass" | "fail" | "agent";
|
|
6
|
+
message: string;
|
|
7
|
+
fix?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Run CLEAR's mechanical checks (Lean, Executable) on a feature file and report all five checks. */
|
|
10
|
+
export declare function clear(text: string): CheckResult[];
|
|
11
|
+
export declare function formatClear(results: CheckResult[]): string;
|
|
12
|
+
/** Find the feature file that carries a story id. */
|
|
13
|
+
export declare function featureFor(story: string, dir: string): string | undefined;
|
|
14
|
+
export declare function runClear(args: string[]): number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DecisionInput {
|
|
2
|
+
title: string;
|
|
3
|
+
why: string;
|
|
4
|
+
story?: string;
|
|
5
|
+
category?: string;
|
|
6
|
+
supersedes?: string;
|
|
7
|
+
date?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Append a dated entry to decisions.md. Earlier entries are never touched. */
|
|
10
|
+
export declare function decide(input: DecisionInput, dir: string): string;
|
|
11
|
+
export declare function runDecide(args: string[]): number;
|
package/dist/commands/decide.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { store } from "../lib/store.js";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { STORY_ID, findIntentDir, read } from "../lib/project.js";
|
|
4
4
|
import { formatResult, validate } from "./validate.js";
|
|
@@ -16,7 +16,7 @@ export function decide(input, dir) {
|
|
|
16
16
|
if (!CATEGORIES.includes(category))
|
|
17
17
|
throw new Error(`Category must be one of ${CATEGORIES.join(", ")}`);
|
|
18
18
|
const path = join(findIntentDir(dir), "decisions.md");
|
|
19
|
-
if (!
|
|
19
|
+
if (!store().exists(path))
|
|
20
20
|
throw new Error("No intent/decisions.md. Run `nikos init` first.");
|
|
21
21
|
const date = input.date ?? new Date().toISOString().slice(0, 10);
|
|
22
22
|
const entry = [
|
|
@@ -29,7 +29,7 @@ export function decide(input, dir) {
|
|
|
29
29
|
].join("\n");
|
|
30
30
|
const existing = read(path);
|
|
31
31
|
const gap = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
32
|
-
|
|
32
|
+
store().append(path, gap + entry);
|
|
33
33
|
return entry;
|
|
34
34
|
}
|
|
35
35
|
export function runDecide(args) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DecisionEntry {
|
|
2
|
+
/** First and last line of the entry in decisions.md, 1-based. */
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
title: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** Every "## " entry in decisions.md, with its line range. HTML comments are skipped. */
|
|
9
|
+
export declare function decisionEntries(dir: string): DecisionEntry[];
|
|
10
|
+
export declare function queryTerms(query: string): string[];
|
|
11
|
+
/** Entries that mention the query, best match first. Entries matching under half as many terms as the best are dropped. */
|
|
12
|
+
export declare function findDecisions(query: string, dir: string): DecisionEntry[];
|
|
13
|
+
export declare function formatDecisions(query: string, found: DecisionEntry[]): string;
|
|
14
|
+
export declare function runDecisions(args: string[]): number;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { store } from "../lib/store.js";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { findIntentDir, read } from "../lib/project.js";
|
|
4
4
|
const STOP = new Set(["the", "and", "for", "why", "did", "was", "were", "are", "you", "how", "what", "with", "this", "that", "not", "use", "using", "instead", "of", "we", "our", "decide", "decided", "choose", "chose", "rather", "than"]);
|
|
5
5
|
/** Every "## " entry in decisions.md, with its line range. HTML comments are skipped. */
|
|
6
6
|
export function decisionEntries(dir) {
|
|
7
7
|
const path = join(findIntentDir(dir), "decisions.md");
|
|
8
|
-
if (!
|
|
8
|
+
if (!store().exists(path))
|
|
9
9
|
return [];
|
|
10
10
|
const lines = read(path).split("\n");
|
|
11
11
|
const entries = [];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Check {
|
|
2
|
+
name: string;
|
|
3
|
+
status: "ok" | "warn" | "fail";
|
|
4
|
+
found: string;
|
|
5
|
+
/** What to do about it, when it isn't ok. */
|
|
6
|
+
next?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function doctor(root: string, opts?: {
|
|
9
|
+
offline?: boolean;
|
|
10
|
+
}): Check[];
|
|
11
|
+
export declare function runDoctor(args: string[]): number;
|