nikos 0.0.0-stage → 0.0.1
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 +32 -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 +95 -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/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,33 @@
|
|
|
1
|
-
|
|
1
|
+
```text
|
|
2
|
+
_ _ ___ ____
|
|
3
|
+
_ __ (_) | | __ / _ \ / ___|
|
|
4
|
+
| '_ \ | | | |/ / | | | | \___ \
|
|
5
|
+
| | | | | | | < | |_| | ___) |
|
|
6
|
+
|_| |_| |_| |_|\_\ \___/ |____/
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
spec-driven development for AI coding agents
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Your story map, plans and acceptance criteria live in your repo as plain
|
|
12
|
+
files and executable Cucumber features, and the agent can't call a story done
|
|
13
|
+
until its scenarios pass.
|
|
14
|
+
|
|
15
|
+
> Early work in progress. Start with [the intent/ format](docs/format.md).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
| Tool | Command |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Claude Code | `/plugin install nikos --marketplace nikovijay/NikOS` |
|
|
22
|
+
| Codex | `codex plugin marketplace add nikovijay/NikOS` |
|
|
23
|
+
| Cursor | Add `https://github.com/nikovijay/NikOS` as a plugin marketplace |
|
|
24
|
+
|
|
25
|
+
Then ask your agent to plan or build a story: the `nikos-mode` skill takes it
|
|
26
|
+
from there. Details and the formats each tool expects: [docs/install.md](docs/install.md).
|
|
27
|
+
|
|
28
|
+
- Home: https://nikos.tools
|
|
29
|
+
- Format spec: [docs/format.md](docs/format.md)
|
|
30
|
+
- Mapping to intentdocs: [docs/intentdocs-mapping.md](docs/intentdocs-mapping.md)
|
|
31
|
+
- Worked example: [examples/hello-todo](examples/hello-todo/intent)
|
|
32
|
+
|
|
33
|
+
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { STORY_ID, findIntentDir, read } from "../lib/project.js";
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const templateDir = [join(here, "../../templates"), join(here, "../templates")].find((d) => existsSync(d));
|
|
8
|
+
export function planVersions(intentDir, story) {
|
|
9
|
+
const plans = join(intentDir, "plans");
|
|
10
|
+
if (!existsSync(plans))
|
|
11
|
+
return [];
|
|
12
|
+
const re = new RegExp(`^${story}\\.v(\\d+)\\.md$`);
|
|
13
|
+
return readdirSync(plans)
|
|
14
|
+
.map((f) => re.exec(f)?.[1])
|
|
15
|
+
.filter((v) => !!v)
|
|
16
|
+
.map(Number)
|
|
17
|
+
.sort((a, b) => a - b);
|
|
18
|
+
}
|
|
19
|
+
export function newPlan(story, opts = {}) {
|
|
20
|
+
if (!STORY_ID.test(story))
|
|
21
|
+
throw new Error(`'${story}' is not a story id (expected e.g. HT-1)`);
|
|
22
|
+
const intentDir = findIntentDir(opts.dir ?? process.cwd());
|
|
23
|
+
const mapPath = join(intentDir, "storymap.yaml");
|
|
24
|
+
if (existsSync(mapPath)) {
|
|
25
|
+
const map = parse(read(mapPath));
|
|
26
|
+
const known = (map?.releases ?? []).some((r) => (r?.stories ?? []).includes(story));
|
|
27
|
+
if (!known)
|
|
28
|
+
throw new Error(`${story} is not in storymap.yaml`);
|
|
29
|
+
}
|
|
30
|
+
const versions = planVersions(intentDir, story);
|
|
31
|
+
const version = (versions.at(-1) ?? 0) + 1;
|
|
32
|
+
const path = join(intentDir, "plans", `${story}.v${version}.md`);
|
|
33
|
+
const content = read(join(templateDir, "plan.md"))
|
|
34
|
+
.replace("{{story}}", story)
|
|
35
|
+
.replace("{{version}}", String(version))
|
|
36
|
+
.replace("{{date}}", opts.date ?? new Date().toISOString().slice(0, 10))
|
|
37
|
+
.replace("{{supersedes}}", version > 1 ? `supersedes: ${version - 1}\n` : "")
|
|
38
|
+
.replace("{{summary}}", opts.summary ?? "One to three plain sentences on the approach.")
|
|
39
|
+
.replace("{{first_step}}", "First step, in eight words or fewer");
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
// "wx" fails if the file exists, so an earlier plan can never be overwritten.
|
|
42
|
+
writeFileSync(path, content, { flag: "wx" });
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
export function runPlan(args) {
|
|
46
|
+
const [sub, story, ...rest] = args;
|
|
47
|
+
if (sub !== "new" || !story) {
|
|
48
|
+
console.error("Usage: nikos plan new <story-id> [--summary \"…\"] [--dir <path>]");
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const opt = (name) => {
|
|
52
|
+
const i = rest.indexOf(`--${name}`);
|
|
53
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const path = newPlan(story, { summary: opt("summary"), dir: opt("dir") });
|
|
57
|
+
console.log(`Created ${path}`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`✗ ${err.message}`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
}
|