nikos 0.0.0-stage → 0.0.2
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 +21 -0
- package/README.md +107 -2
- package/dist/cli.js +60 -0
- package/dist/commands/bet.js +97 -0
- package/dist/commands/clear.js +100 -0
- package/dist/commands/decide.js +60 -0
- package/dist/commands/decisions.js +71 -0
- package/dist/commands/init.js +72 -0
- package/dist/commands/next.js +35 -0
- package/dist/commands/plan.js +64 -0
- package/dist/commands/show.js +82 -0
- package/dist/commands/state.js +104 -0
- package/dist/commands/validate.js +206 -0
- package/dist/commands/verify.js +99 -0
- package/dist/lib/project.js +42 -0
- package/dist/lint/constitution.js +40 -0
- package/dist/lint/gherkin.js +99 -0
- package/dist/lint/portable.js +66 -0
- package/dist/verify/detect.js +21 -0
- package/docs/format.md +296 -0
- package/docs/gherkin.md +81 -0
- package/docs/install.md +72 -0
- package/docs/intentdocs-mapping.md +117 -0
- package/docs/quickstart.md +153 -0
- package/package.json +44 -4
- package/schemas/bet.schema.json +23 -0
- package/schemas/state.schema.json +34 -0
- package/schemas/storymap.schema.json +43 -0
- package/skills/clear/SKILL.md +55 -0
- package/skills/clear-review/SKILL.md +45 -0
- package/skills/decisions/SKILL.md +45 -0
- package/skills/intent/SKILL.md +39 -0
- package/skills/nikos-mode/SKILL.md +61 -0
- package/skills/nikos-mode/playbooks/answer.md +9 -0
- package/skills/nikos-mode/playbooks/bet.md +14 -0
- package/skills/nikos-mode/playbooks/build-a-story.md +14 -0
- package/skills/nikos-mode/playbooks/change.md +12 -0
- package/skills/nikos-mode/playbooks/idea-to-blueprint.md +16 -0
- package/skills/nikos-mode/playbooks/index.md +13 -0
- package/skills/nikos-mode/playbooks/validate.md +17 -0
- package/skills/nikos-mode/principles/index.md +14 -0
- package/skills/verify/SKILL.md +49 -0
- package/templates/intent/constitution.md +19 -0
- package/templates/intent/decisions.md +12 -0
- package/templates/intent/features/getting-started/first-story.feature +12 -0
- package/templates/intent/plans/.gitkeep +0 -0
- package/templates/intent/state.yaml +4 -0
- package/templates/intent/storymap.yaml +10 -0
- package/templates/plan.md +13 -0
- package/templates/verify/js/cucumber.mjs +6 -0
- package/templates/verify/js/test/steps/first-story.steps.mjs +16 -0
- package/templates/verify/python/behave.ini +4 -0
- package/templates/verify/python/steps/first_story_steps.py +19 -0
- package/templates/verify/ts/cucumber.mjs +6 -0
- package/templates/verify/ts/test/steps/first-story.steps.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niko Vijay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
|
|
1
|
+
```text
|
|
2
|
+
_ _ ___ ____
|
|
3
|
+
_ __ (_) | | __ / _ \ / ___|
|
|
4
|
+
| '_ \ | | | |/ / | | | | \___ \
|
|
5
|
+
| | | | | | | < | |_| | ___) |
|
|
6
|
+
|_| |_| |_| |_|\_\ \___/ |____/
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
spec-driven development for AI coding agents
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
AI coding agents are quick to say "done". nikOS makes them prove it. Your
|
|
12
|
+
story map, plans and acceptance criteria live in your repo as plain files,
|
|
13
|
+
each criterion is a Cucumber scenario that runs as a test, and a story can't
|
|
14
|
+
be marked done until its scenarios pass. Your agent follows a named playbook
|
|
15
|
+
for each request instead of improvising, and every decision is written down
|
|
16
|
+
where the next person (or agent) can find it.
|
|
17
|
+
|
|
18
|
+
> Early work in progress. Open source, MIT licensed.
|
|
19
|
+
|
|
20
|
+
## Try it in 10 minutes
|
|
21
|
+
|
|
22
|
+
[docs/quickstart.md](docs/quickstart.md) takes an empty repo to one passing
|
|
23
|
+
scenario, with every command and the output you should see.
|
|
24
|
+
|
|
25
|
+
## Install in your agent tool
|
|
26
|
+
|
|
27
|
+
| Tool | Command |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Claude Code | `/plugin install nikos --marketplace nikovijay/NikOS` |
|
|
30
|
+
| Codex | `codex plugin marketplace add nikovijay/NikOS` |
|
|
31
|
+
| Cursor | Add `https://github.com/nikovijay/NikOS` as a plugin marketplace |
|
|
32
|
+
|
|
33
|
+
Then ask your agent to plan or build a story: the `nikos-mode` skill takes it
|
|
34
|
+
from there. The skills run the `nikos` command through `npx`, so there's
|
|
35
|
+
nothing else to install. More in [docs/install.md](docs/install.md).
|
|
36
|
+
|
|
37
|
+
## The loop
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx -y nikos init # create intent/ with a starter story
|
|
41
|
+
# write the story's scenarios in intent/features/
|
|
42
|
+
npx -y nikos verify setup # add a Cucumber runner for your stack
|
|
43
|
+
npx -y nikos verify TC-1 # run the story: it fails until it's built
|
|
44
|
+
# build it, then
|
|
45
|
+
npx -y nikos state set TC-1 done # refused unless its scenarios pass
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
With an agent, you only write the request ("add a story for X and build
|
|
49
|
+
it"); the playbooks run these steps and stop at each point that needs you.
|
|
50
|
+
|
|
51
|
+
## What lives in your repo
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
intent/
|
|
55
|
+
├── constitution.md who it's for, the problem, what proves it works
|
|
56
|
+
├── storymap.yaml activities, releases, and the order to build stories in
|
|
57
|
+
├── state.yaml each story's status: todo, in_progress, needs_rework, done
|
|
58
|
+
├── decisions.md why things are the way they are, append-only
|
|
59
|
+
├── features/ one .feature file per story; its scenarios are the criteria
|
|
60
|
+
│ └── <activity>/<story>.feature
|
|
61
|
+
├── plans/ how each story will be built, versioned: <story>.v<N>.md
|
|
62
|
+
└── bets/ time-boxed bets and the stories that test them
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every file is plain text you can read, review and diff. The full format:
|
|
66
|
+
[docs/format.md](docs/format.md). A worked example:
|
|
67
|
+
[examples/hello-todo](examples/hello-todo/intent).
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
| Command | What it does |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `nikos init` | Create `intent/` in this repo |
|
|
74
|
+
| `nikos validate` | Check `intent/` is well formed |
|
|
75
|
+
| `nikos clear <id>` | Check a story is ready to build |
|
|
76
|
+
| `nikos plan new <id>` | Start the next version of a story's plan |
|
|
77
|
+
| `nikos verify setup` | Add a Cucumber runner for this repo's stack |
|
|
78
|
+
| `nikos verify [id]` | Run a story's scenarios, or all of them |
|
|
79
|
+
| `nikos state set <id> <status>` | Move a story; done only if its scenarios pass |
|
|
80
|
+
| `nikos next` | Print the next story to build |
|
|
81
|
+
| `nikos show <id>` | Summarise a story: feature, status, plan, bets, decisions |
|
|
82
|
+
| `nikos decide "<title>"` | Append a decision to `decisions.md` |
|
|
83
|
+
| `nikos decisions "<q>"` | Quote the decisions about a topic or story |
|
|
84
|
+
| `nikos bet new <id>` | Write a time-boxed bet card linked to its stories |
|
|
85
|
+
|
|
86
|
+
## Working with a team: intentdocs
|
|
87
|
+
|
|
88
|
+
When you want a visual story map, reviews and a team around the same specs,
|
|
89
|
+
[intentdocs](https://www.intentdocs.com) reads the same `intent/` folder:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx intentdocs push # upload intent/ and link this repo
|
|
93
|
+
npx intentdocs pull # bring web edits back, shown as diffs first
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The repo stays the source of truth for specs and plans; intentdocs runs the
|
|
97
|
+
build and acceptance steps. What maps to what:
|
|
98
|
+
[docs/intentdocs-mapping.md](docs/intentdocs-mapping.md).
|
|
99
|
+
|
|
100
|
+
## Links
|
|
101
|
+
|
|
102
|
+
- Home: https://nikos.tools
|
|
103
|
+
- Quickstart: [docs/quickstart.md](docs/quickstart.md)
|
|
104
|
+
- Install: [docs/install.md](docs/install.md)
|
|
105
|
+
- Format: [docs/format.md](docs/format.md)
|
|
106
|
+
- Writing stories as features: [docs/gherkin.md](docs/gherkin.md)
|
|
107
|
+
|
|
108
|
+
MIT licensed.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runValidate } from "./commands/validate.js";
|
|
3
|
+
import { runPlan } from "./commands/plan.js";
|
|
4
|
+
import { runInit } from "./commands/init.js";
|
|
5
|
+
import { runClear } from "./commands/clear.js";
|
|
6
|
+
import { runVerify } from "./commands/verify.js";
|
|
7
|
+
import { runNext } from "./commands/next.js";
|
|
8
|
+
import { runState } from "./commands/state.js";
|
|
9
|
+
import { runDecide } from "./commands/decide.js";
|
|
10
|
+
import { runBet } from "./commands/bet.js";
|
|
11
|
+
import { runShow } from "./commands/show.js";
|
|
12
|
+
import { runDecisions } from "./commands/decisions.js";
|
|
13
|
+
const commands = {
|
|
14
|
+
init: runInit,
|
|
15
|
+
validate: runValidate,
|
|
16
|
+
plan: runPlan,
|
|
17
|
+
clear: runClear,
|
|
18
|
+
verify: runVerify,
|
|
19
|
+
next: runNext,
|
|
20
|
+
state: runState,
|
|
21
|
+
decide: runDecide,
|
|
22
|
+
bet: runBet,
|
|
23
|
+
show: runShow,
|
|
24
|
+
decisions: runDecisions,
|
|
25
|
+
};
|
|
26
|
+
const help = `nikos — spec-driven development for AI coding agents
|
|
27
|
+
|
|
28
|
+
Usage: nikos <command> [options]
|
|
29
|
+
|
|
30
|
+
Commands:
|
|
31
|
+
init Create intent/ in this repo (--name, --key)
|
|
32
|
+
validate [dir] Check the intent/ folder is well formed
|
|
33
|
+
plan new <id> Start the next version of a story's plan
|
|
34
|
+
clear <id> Check a story is Lean and Executable before building
|
|
35
|
+
verify setup Set up a Cucumber runner for this repo's stack
|
|
36
|
+
verify [id] Run a story's scenarios (or all of them)
|
|
37
|
+
show <id> Summarise a story: feature, status, plan, bets, decisions
|
|
38
|
+
decisions "<q>" Quote the decisions.md entries that mention a topic or story
|
|
39
|
+
next Print the next story to build
|
|
40
|
+
state set <id> <status> Move a story; done only if its scenarios pass
|
|
41
|
+
decide "<title>" Append a decision to decisions.md
|
|
42
|
+
(--why, --story, --category, --supersedes)
|
|
43
|
+
bet new <bet-id> Write a time-boxed bet card linked to its stories
|
|
44
|
+
(--hypothesis, --kr, --uplift, --time-box, --story, --blocker)
|
|
45
|
+
|
|
46
|
+
Docs: https://nikos.tools`;
|
|
47
|
+
async function main(argv) {
|
|
48
|
+
const [cmd, ...rest] = argv;
|
|
49
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
50
|
+
console.log(help);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const run = commands[cmd];
|
|
54
|
+
if (!run) {
|
|
55
|
+
console.error(`Unknown command '${cmd}'.\n\n${help}`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
return run(rest);
|
|
59
|
+
}
|
|
60
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { Document, parse } from "yaml";
|
|
4
|
+
import { STORY_ID, findIntentDir, read } from "../lib/project.js";
|
|
5
|
+
export const BET_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
6
|
+
/** Story ids in any release of storymap.yaml, or undefined if there is no story map. */
|
|
7
|
+
function mapStories(intentDir) {
|
|
8
|
+
const path = join(intentDir, "storymap.yaml");
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return undefined;
|
|
11
|
+
const map = parse(read(path));
|
|
12
|
+
return new Set((map?.releases ?? []).flatMap((r) => r?.stories ?? []));
|
|
13
|
+
}
|
|
14
|
+
/** Split a bet card into its front matter and body. */
|
|
15
|
+
export function parseBet(text) {
|
|
16
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
17
|
+
if (!m)
|
|
18
|
+
return undefined;
|
|
19
|
+
return { meta: parse(m[1]), body: text.slice(m[0].length) };
|
|
20
|
+
}
|
|
21
|
+
/** Write intent/bets/<id>.md with every field filled in, linked to stories that exist. */
|
|
22
|
+
export function newBet(input, dir) {
|
|
23
|
+
if (!BET_ID.test(input.id))
|
|
24
|
+
throw new Error(`'${input.id}' is not a bet id (lowercase words joined by hyphens, e.g. faster-payment)`);
|
|
25
|
+
const fields = [
|
|
26
|
+
["hypothesis", input.hypothesis],
|
|
27
|
+
["key result (--kr)", input.keyResult],
|
|
28
|
+
["expected uplift (--uplift)", input.expectedUplift],
|
|
29
|
+
];
|
|
30
|
+
for (const [name, value] of fields)
|
|
31
|
+
if (!value?.trim())
|
|
32
|
+
throw new Error(`A bet needs a ${name}`);
|
|
33
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.timeBox ?? ""))
|
|
34
|
+
throw new Error("A bet needs a time box end date (--time-box YYYY-MM-DD)");
|
|
35
|
+
if (!input.stories.length)
|
|
36
|
+
throw new Error("A bet needs at least one story (--story <id>)");
|
|
37
|
+
const bad = input.stories.filter((s) => !STORY_ID.test(s));
|
|
38
|
+
if (bad.length)
|
|
39
|
+
throw new Error(`Not story ids: ${bad.join(", ")} (expected e.g. HT-1)`);
|
|
40
|
+
const intentDir = findIntentDir(dir);
|
|
41
|
+
const known = mapStories(intentDir);
|
|
42
|
+
const unknown = known ? input.stories.filter((s) => !known.has(s)) : [];
|
|
43
|
+
if (unknown.length)
|
|
44
|
+
throw new Error(`Not in storymap.yaml: ${unknown.join(", ")}`);
|
|
45
|
+
const meta = {
|
|
46
|
+
id: input.id,
|
|
47
|
+
hypothesis: input.hypothesis.trim(),
|
|
48
|
+
key_result: input.keyResult.trim(),
|
|
49
|
+
expected_uplift: input.expectedUplift.trim(),
|
|
50
|
+
time_box: input.timeBox,
|
|
51
|
+
blockers: input.blockers ?? [],
|
|
52
|
+
stories: [...new Set(input.stories)],
|
|
53
|
+
status: "open",
|
|
54
|
+
};
|
|
55
|
+
const body = "\n## Why we think so\nThe evidence behind the hypothesis.\n\n## Outcome\nFilled in when the time box ends: the key result as measured, and won, lost or killed.\n";
|
|
56
|
+
const path = join(intentDir, "bets", `${input.id}.md`);
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
// "wx" fails if the file exists, so an existing bet is never overwritten.
|
|
59
|
+
try {
|
|
60
|
+
const doc = new Document(meta);
|
|
61
|
+
for (const key of ["blockers", "stories"])
|
|
62
|
+
doc.get(key, true).flow = true;
|
|
63
|
+
writeFileSync(path, `---\n${doc.toString({ flowCollectionPadding: false, lineWidth: 0 })}---\n${body}`, { flag: "wx" });
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err.code === "EEXIST")
|
|
67
|
+
throw new Error(`A bet called '${input.id}' already exists`);
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
return path;
|
|
71
|
+
}
|
|
72
|
+
export function runBet(args) {
|
|
73
|
+
const [sub, id, ...rest] = args;
|
|
74
|
+
const all = (n) => rest.flatMap((a, i) => (a === `--${n}` && rest[i + 1] !== undefined ? [rest[i + 1]] : []));
|
|
75
|
+
const opt = (n) => all(n)[0];
|
|
76
|
+
if (sub !== "new" || !id || id.startsWith("--")) {
|
|
77
|
+
console.error('Usage: nikos bet new <bet-id> --hypothesis "<…>" --kr "<key result>" --uplift "<expected uplift>" --time-box YYYY-MM-DD --story <id> [--story <id>] [--blocker "<…>"]');
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const path = newBet({
|
|
82
|
+
id,
|
|
83
|
+
hypothesis: opt("hypothesis") ?? "",
|
|
84
|
+
keyResult: opt("kr") ?? "",
|
|
85
|
+
expectedUplift: opt("uplift") ?? "",
|
|
86
|
+
timeBox: opt("time-box") ?? "",
|
|
87
|
+
stories: all("story"),
|
|
88
|
+
blockers: all("blocker"),
|
|
89
|
+
}, opt("dir") ?? process.cwd());
|
|
90
|
+
console.log(`Created ${path}`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(`✗ ${err.message}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { featureStoryIds, findIntentDir, read, rel, walk } from "../lib/project.js";
|
|
3
|
+
import { parseFeature, scenariosOf } from "../lint/gherkin.js";
|
|
4
|
+
const CHECKS = ["Clear", "Lean", "Executable", "Accurate", "Responsible"];
|
|
5
|
+
/** Implementation detail that doesn't belong in a spec. Case-sensitive names avoid ordinary English words. */
|
|
6
|
+
const LEAN_RULES = [
|
|
7
|
+
{ what: "a file path", re: /\b[\w-]+(?:\/[\w.-]+)*\.(?:ts|tsx|js|jsx|py|rb|go|java|kt|swift|sql|json|ya?ml|css|html)\b/ },
|
|
8
|
+
{ what: "SQL", re: /\b(?:SELECT\s+[\w*, ]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|CREATE\s+TABLE|ALTER\s+TABLE)\b/i },
|
|
9
|
+
{
|
|
10
|
+
what: "a framework or storage name",
|
|
11
|
+
re: /\b(?:React|Vue|Angular|Svelte|Next\.js|Django|Flask|Laravel|PostgreSQL|Postgres|MySQL|MongoDB|Redis|SQLite|localStorage|sessionStorage|IndexedDB|GraphQL|Kafka|DynamoDB|Firebase|Supabase|Docker|Kubernetes)\b/,
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
const JUDGED = {
|
|
15
|
+
Clear: "needs a reader: one reading, no ambiguity",
|
|
16
|
+
Accurate: "needs a reader: matches constitution.md and decisions.md",
|
|
17
|
+
Responsible: "needs a reader: privacy, safety and accessibility considered",
|
|
18
|
+
};
|
|
19
|
+
/** Run CLEAR's mechanical checks (Lean, Executable) on a feature file and report all five checks. */
|
|
20
|
+
export function clear(text) {
|
|
21
|
+
const results = new Map();
|
|
22
|
+
for (const check of ["Clear", "Accurate", "Responsible"]) {
|
|
23
|
+
results.set(check, { check, status: "agent", message: JUDGED[check] });
|
|
24
|
+
}
|
|
25
|
+
let feature;
|
|
26
|
+
try {
|
|
27
|
+
feature = parseFeature(text);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
feature = undefined;
|
|
31
|
+
results.set("Executable", {
|
|
32
|
+
check: "Executable",
|
|
33
|
+
status: "fail",
|
|
34
|
+
message: `isn't valid Gherkin: ${err.message.split("\n")[0]}`,
|
|
35
|
+
fix: "Fix the Gherkin syntax so the feature can run",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (feature) {
|
|
39
|
+
const scenarios = scenariosOf(feature);
|
|
40
|
+
// Given is optional (docs/gherkin.md); When and Then are what make a scenario runnable.
|
|
41
|
+
const incomplete = scenarios.filter((s) => {
|
|
42
|
+
const keywords = new Set(s.steps.map((st) => st.keyword.trim()));
|
|
43
|
+
return !keywords.has("When") || !keywords.has("Then");
|
|
44
|
+
});
|
|
45
|
+
if (scenarios.length === 0) {
|
|
46
|
+
results.set("Executable", { check: "Executable", status: "fail", message: "has no scenarios", fix: "Add at least one Scenario for each acceptance criterion" });
|
|
47
|
+
}
|
|
48
|
+
else if (incomplete.length) {
|
|
49
|
+
results.set("Executable", {
|
|
50
|
+
check: "Executable",
|
|
51
|
+
status: "fail",
|
|
52
|
+
message: `${incomplete.map((s) => `"${s.name}"`).join(", ")} ${incomplete.length === 1 ? "is" : "are"} missing a When or Then step`,
|
|
53
|
+
fix: "Give every Scenario a When (the action) and a Then (the outcome)",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
results.set("Executable", { check: "Executable", status: "pass", message: `${scenarios.length} scenario${scenarios.length === 1 ? "" : "s"}, each with When and Then` });
|
|
58
|
+
}
|
|
59
|
+
// Only the prose: names, descriptions and steps. Tags are metadata.
|
|
60
|
+
const prose = [feature.name, feature.description, ...scenarios.flatMap((s) => [s.name, s.description, ...s.steps.map((st) => st.text)])].join("\n");
|
|
61
|
+
const found = LEAN_RULES.flatMap(({ what, re }) => {
|
|
62
|
+
const m = re.exec(prose);
|
|
63
|
+
return m ? [`${what} ("${m[0]}")`] : [];
|
|
64
|
+
});
|
|
65
|
+
results.set("Lean", found.length
|
|
66
|
+
? { check: "Lean", status: "fail", message: `names ${found.join(", ")}`, fix: "Describe what the user sees and does; move implementation detail to the plan" }
|
|
67
|
+
: { check: "Lean", status: "pass", message: "no implementation detail found" });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
results.set("Lean", { check: "Lean", status: "fail", message: "can't be checked until the feature parses", fix: "Fix the Gherkin syntax first" });
|
|
71
|
+
}
|
|
72
|
+
return CHECKS.map((c) => results.get(c));
|
|
73
|
+
}
|
|
74
|
+
export function formatClear(results) {
|
|
75
|
+
const mark = { pass: "✓", fail: "✗", agent: "?" };
|
|
76
|
+
return results.map((r) => `${mark[r.status]} ${r.check.padEnd(11)} ${r.message}${r.fix ? `. Fix: ${r.fix}` : ""}`).join("\n");
|
|
77
|
+
}
|
|
78
|
+
/** Find the feature file that carries a story id. */
|
|
79
|
+
export function featureFor(story, dir) {
|
|
80
|
+
return walk(join(findIntentDir(dir), "features"), ".feature").find((f) => featureStoryIds(read(f)).includes(story));
|
|
81
|
+
}
|
|
82
|
+
export function runClear(args) {
|
|
83
|
+
const [story, ...rest] = args;
|
|
84
|
+
if (!story) {
|
|
85
|
+
console.error("Usage: nikos clear <story-id> [--dir <path>]");
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const i = rest.indexOf("--dir");
|
|
89
|
+
const dir = i >= 0 ? rest[i + 1] : process.cwd();
|
|
90
|
+
const file = featureFor(story, dir);
|
|
91
|
+
if (!file) {
|
|
92
|
+
console.error(`✗ No feature file is tagged @${story}`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const results = clear(read(file));
|
|
96
|
+
console.log(`CLEAR ${story} (${rel(process.cwd(), file)})\n${formatClear(results)}`);
|
|
97
|
+
if (results.some((r) => r.status === "agent"))
|
|
98
|
+
console.log("\n? = judged by the agent with the clear skill");
|
|
99
|
+
return results.some((r) => r.status === "fail") ? 1 : 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { STORY_ID, findIntentDir, read } from "../lib/project.js";
|
|
4
|
+
import { formatResult, validate } from "./validate.js";
|
|
5
|
+
const CATEGORIES = ["product", "architecture", "design", "process"];
|
|
6
|
+
/** Append a dated entry to decisions.md. Earlier entries are never touched. */
|
|
7
|
+
export function decide(input, dir) {
|
|
8
|
+
const title = input.title.trim();
|
|
9
|
+
if (!title)
|
|
10
|
+
throw new Error("A decision needs a title");
|
|
11
|
+
if (!input.why.trim())
|
|
12
|
+
throw new Error("Say why: what you chose and what you rejected (--why)");
|
|
13
|
+
if (input.story && !STORY_ID.test(input.story))
|
|
14
|
+
throw new Error(`'${input.story}' is not a story id (expected e.g. HT-1)`);
|
|
15
|
+
const category = input.category ?? "architecture";
|
|
16
|
+
if (!CATEGORIES.includes(category))
|
|
17
|
+
throw new Error(`Category must be one of ${CATEGORIES.join(", ")}`);
|
|
18
|
+
const path = join(findIntentDir(dir), "decisions.md");
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
throw new Error("No intent/decisions.md. Run `nikos init` first.");
|
|
21
|
+
const date = input.date ?? new Date().toISOString().slice(0, 10);
|
|
22
|
+
const entry = [
|
|
23
|
+
`## ${date} — ${title}${input.story ? ` (${input.story})` : ""}`,
|
|
24
|
+
`**Category:** ${category}`,
|
|
25
|
+
`**Supersedes:** ${input.supersedes ?? "none"}`,
|
|
26
|
+
"",
|
|
27
|
+
input.why.trim(),
|
|
28
|
+
"",
|
|
29
|
+
].join("\n");
|
|
30
|
+
const existing = read(path);
|
|
31
|
+
const gap = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
32
|
+
appendFileSync(path, gap + entry);
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
export function runDecide(args) {
|
|
36
|
+
const [title, ...rest] = args;
|
|
37
|
+
const opt = (n) => {
|
|
38
|
+
const i = rest.indexOf(`--${n}`);
|
|
39
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
40
|
+
};
|
|
41
|
+
if (!title || title.startsWith("--")) {
|
|
42
|
+
console.error('Usage: nikos decide "<title>" --why "<why>" [--story <id>] [--category product|architecture|design|process] [--supersedes "<title>"]');
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
const dir = opt("dir") ?? process.cwd();
|
|
46
|
+
try {
|
|
47
|
+
decide({ title, why: opt("why") ?? "", story: opt("story"), category: opt("category"), supersedes: opt("supersedes") }, dir);
|
|
48
|
+
console.log(`Added to decisions.md: ${title}`);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error(`✗ ${err.message}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const v = validate(dir);
|
|
55
|
+
if (v.errors.length) {
|
|
56
|
+
console.error(formatResult(v));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { findIntentDir, read } from "../lib/project.js";
|
|
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
|
+
/** Every "## " entry in decisions.md, with its line range. HTML comments are skipped. */
|
|
6
|
+
export function decisionEntries(dir) {
|
|
7
|
+
const path = join(findIntentDir(dir), "decisions.md");
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return [];
|
|
10
|
+
const lines = read(path).split("\n");
|
|
11
|
+
const entries = [];
|
|
12
|
+
let inComment = false;
|
|
13
|
+
let current;
|
|
14
|
+
lines.forEach((line, i) => {
|
|
15
|
+
if (line.trim().startsWith("<!--"))
|
|
16
|
+
inComment = true;
|
|
17
|
+
const commented = inComment;
|
|
18
|
+
if (line.includes("-->"))
|
|
19
|
+
inComment = false;
|
|
20
|
+
if (commented)
|
|
21
|
+
return;
|
|
22
|
+
if (line.startsWith("## ")) {
|
|
23
|
+
current = { start: i + 1, end: i + 1, title: line.slice(3).trim(), text: line };
|
|
24
|
+
entries.push(current);
|
|
25
|
+
}
|
|
26
|
+
else if (current && !line.startsWith("# ")) {
|
|
27
|
+
current.text += `\n${line}`;
|
|
28
|
+
if (line.trim())
|
|
29
|
+
current.end = i + 1;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
for (const e of entries)
|
|
33
|
+
e.text = e.text.trimEnd();
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
export function queryTerms(query) {
|
|
37
|
+
return [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}-]+/u).filter((w) => w.length >= 3 && !STOP.has(w)))];
|
|
38
|
+
}
|
|
39
|
+
/** Entries that mention the query, best match first. Entries matching under half as many terms as the best are dropped. */
|
|
40
|
+
export function findDecisions(query, dir) {
|
|
41
|
+
const terms = queryTerms(query);
|
|
42
|
+
if (!terms.length)
|
|
43
|
+
return [];
|
|
44
|
+
const scored = decisionEntries(dir)
|
|
45
|
+
.map((e) => {
|
|
46
|
+
const text = e.text.toLowerCase();
|
|
47
|
+
return { e, hits: terms.filter((t) => new RegExp(`(^|[^\\p{L}\\p{N}])${t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(text)).length };
|
|
48
|
+
})
|
|
49
|
+
.filter((m) => m.hits > 0);
|
|
50
|
+
const best = Math.max(0, ...scored.map((m) => m.hits));
|
|
51
|
+
return scored
|
|
52
|
+
.filter((m) => m.hits * 2 >= best)
|
|
53
|
+
.sort((a, b) => b.hits - a.hits || a.e.start - b.e.start)
|
|
54
|
+
.map((m) => m.e);
|
|
55
|
+
}
|
|
56
|
+
export function formatDecisions(query, found) {
|
|
57
|
+
if (!found.length)
|
|
58
|
+
return `No decision in intent/decisions.md mentions "${query}".`;
|
|
59
|
+
return found.map((e) => `intent/decisions.md:${e.start}-${e.end}\n${e.text}`).join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
export function runDecisions(args) {
|
|
62
|
+
const i = args.indexOf("--dir");
|
|
63
|
+
const dir = i >= 0 ? args[i + 1] : process.cwd();
|
|
64
|
+
const query = (i >= 0 ? [...args.slice(0, i), ...args.slice(i + 2)] : args).join(" ").trim();
|
|
65
|
+
if (!query) {
|
|
66
|
+
console.error('Usage: nikos decisions "<topic or story id>" [--dir <path>]');
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
console.log(formatDecisions(query, findDecisions(query, dir)));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { walk } from "../lib/project.js";
|
|
5
|
+
import { validate, formatResult } from "./validate.js";
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const templateDir = [join(here, "../../templates/intent"), join(here, "../templates/intent")].find((d) => existsSync(d));
|
|
8
|
+
export function slugify(name) {
|
|
9
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
10
|
+
}
|
|
11
|
+
/** "Hello Todo" → "HT", "nikos" → "NIK". */
|
|
12
|
+
export function keyFor(name) {
|
|
13
|
+
const words = name.replace(/[^A-Za-z0-9 ]+/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
14
|
+
const key = words.length > 1 ? words.map((w) => w[0]).join("") : (words[0] ?? "P").slice(0, 3);
|
|
15
|
+
const clean = key.toUpperCase().replace(/^[0-9]+/, "");
|
|
16
|
+
return (clean || "P").slice(0, 6);
|
|
17
|
+
}
|
|
18
|
+
export function init(root, opts = {}) {
|
|
19
|
+
const name = opts.name ?? basename(resolve(root));
|
|
20
|
+
const key = (opts.key ?? keyFor(name)).toUpperCase();
|
|
21
|
+
if (!/^[A-Z][A-Z0-9]{0,9}$/.test(key))
|
|
22
|
+
throw new Error(`'${key}' isn't a valid project key (letters and digits, starting with a letter)`);
|
|
23
|
+
const values = { project_name: name, project_slug: slugify(name), key };
|
|
24
|
+
const intentDir = join(root, "intent");
|
|
25
|
+
const created = [];
|
|
26
|
+
const skipped = [];
|
|
27
|
+
for (const src of walk(templateDir)) {
|
|
28
|
+
const rel = relative(templateDir, src);
|
|
29
|
+
const dest = join(intentDir, rel);
|
|
30
|
+
if (existsSync(dest)) {
|
|
31
|
+
skipped.push(rel);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
35
|
+
if (rel.endsWith(".gitkeep")) {
|
|
36
|
+
copyFileSync(src, dest);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const text = readFileSync(src, "utf8").replace(/\{\{(\w+)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`);
|
|
40
|
+
// "wx" guards against a race where the file appears between the check and the write.
|
|
41
|
+
writeFileSync(dest, text, { flag: "wx" });
|
|
42
|
+
}
|
|
43
|
+
created.push(rel);
|
|
44
|
+
}
|
|
45
|
+
return { intentDir, created, skipped, validation: validate(intentDir) };
|
|
46
|
+
}
|
|
47
|
+
export function runInit(args) {
|
|
48
|
+
const opt = (n) => {
|
|
49
|
+
const i = args.indexOf(`--${n}`);
|
|
50
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const r = init(opt("dir") ?? process.cwd(), { name: opt("name"), key: opt("key") });
|
|
54
|
+
if (r.skipped.length === 0) {
|
|
55
|
+
console.log(`Created intent/ (${r.created.length} files)`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (r.created.length)
|
|
59
|
+
console.log(`Created:\n${r.created.map((f) => ` + intent/${f}`).join("\n")}`);
|
|
60
|
+
console.log(`Skipped (already there):\n${r.skipped.map((f) => ` = intent/${f}`).join("\n")}`);
|
|
61
|
+
}
|
|
62
|
+
console.log(formatResult(r.validation));
|
|
63
|
+
if (r.validation.errors.length === 0 && r.skipped.length === 0) {
|
|
64
|
+
console.log("\nNext: fill in intent/constitution.md, then write your first story in intent/features/.");
|
|
65
|
+
}
|
|
66
|
+
return r.validation.errors.length ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(`✗ ${err.message}`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { findIntentDir, read } from "../lib/project.js";
|
|
4
|
+
/** The first todo story in run.queue, then in story map order. */
|
|
5
|
+
export function next(dir) {
|
|
6
|
+
const intentDir = findIntentDir(dir);
|
|
7
|
+
const map = parse(read(join(intentDir, "storymap.yaml")));
|
|
8
|
+
const state = parse(read(join(intentDir, "state.yaml")));
|
|
9
|
+
const stories = state?.stories ?? {};
|
|
10
|
+
const mapOrder = (map?.releases ?? [])
|
|
11
|
+
.slice()
|
|
12
|
+
.sort((a, b) => a.number - b.number)
|
|
13
|
+
.flatMap((r) => r.stories ?? []);
|
|
14
|
+
const order = [...new Set([...(state?.run?.queue ?? []), ...mapOrder])];
|
|
15
|
+
const open = order.filter((id) => ["in_progress", "needs_rework"].includes(stories[id]?.status ?? "")).map((id) => ({ id, status: stories[id].status }));
|
|
16
|
+
return { story: order.find((id) => (stories[id]?.status ?? "todo") === "todo"), open };
|
|
17
|
+
}
|
|
18
|
+
export function runNext(args) {
|
|
19
|
+
const i = args.indexOf("--dir");
|
|
20
|
+
try {
|
|
21
|
+
const r = next(i >= 0 ? args[i + 1] : process.cwd());
|
|
22
|
+
for (const o of r.open)
|
|
23
|
+
console.log(`! ${o.id} is ${o.status}; finish it first`);
|
|
24
|
+
if (!r.story) {
|
|
25
|
+
console.log("Nothing left to do: every story is started or done.");
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
console.log(r.story);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`✗ ${err.message}`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
}
|