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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -2
  3. package/dist/cli.js +60 -0
  4. package/dist/commands/bet.js +97 -0
  5. package/dist/commands/clear.js +100 -0
  6. package/dist/commands/decide.js +60 -0
  7. package/dist/commands/decisions.js +71 -0
  8. package/dist/commands/init.js +72 -0
  9. package/dist/commands/next.js +35 -0
  10. package/dist/commands/plan.js +64 -0
  11. package/dist/commands/show.js +82 -0
  12. package/dist/commands/state.js +104 -0
  13. package/dist/commands/validate.js +206 -0
  14. package/dist/commands/verify.js +99 -0
  15. package/dist/lib/project.js +42 -0
  16. package/dist/lint/constitution.js +40 -0
  17. package/dist/lint/gherkin.js +99 -0
  18. package/dist/lint/portable.js +66 -0
  19. package/dist/verify/detect.js +21 -0
  20. package/docs/format.md +296 -0
  21. package/docs/gherkin.md +81 -0
  22. package/docs/install.md +72 -0
  23. package/docs/intentdocs-mapping.md +117 -0
  24. package/docs/quickstart.md +153 -0
  25. package/package.json +44 -4
  26. package/schemas/bet.schema.json +23 -0
  27. package/schemas/state.schema.json +34 -0
  28. package/schemas/storymap.schema.json +43 -0
  29. package/skills/clear/SKILL.md +55 -0
  30. package/skills/clear-review/SKILL.md +45 -0
  31. package/skills/decisions/SKILL.md +45 -0
  32. package/skills/intent/SKILL.md +39 -0
  33. package/skills/nikos-mode/SKILL.md +61 -0
  34. package/skills/nikos-mode/playbooks/answer.md +9 -0
  35. package/skills/nikos-mode/playbooks/bet.md +14 -0
  36. package/skills/nikos-mode/playbooks/build-a-story.md +14 -0
  37. package/skills/nikos-mode/playbooks/change.md +12 -0
  38. package/skills/nikos-mode/playbooks/idea-to-blueprint.md +16 -0
  39. package/skills/nikos-mode/playbooks/index.md +13 -0
  40. package/skills/nikos-mode/playbooks/validate.md +17 -0
  41. package/skills/nikos-mode/principles/index.md +14 -0
  42. package/skills/verify/SKILL.md +49 -0
  43. package/templates/intent/constitution.md +19 -0
  44. package/templates/intent/decisions.md +12 -0
  45. package/templates/intent/features/getting-started/first-story.feature +12 -0
  46. package/templates/intent/plans/.gitkeep +0 -0
  47. package/templates/intent/state.yaml +4 -0
  48. package/templates/intent/storymap.yaml +10 -0
  49. package/templates/plan.md +13 -0
  50. package/templates/verify/js/cucumber.mjs +6 -0
  51. package/templates/verify/js/test/steps/first-story.steps.mjs +16 -0
  52. package/templates/verify/python/behave.ini +4 -0
  53. package/templates/verify/python/steps/first_story_steps.py +19 -0
  54. package/templates/verify/ts/cucumber.mjs +6 -0
  55. 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
- # Temporary Holding Version
1
+ ```text
2
+ _ _ ___ ____
3
+ _ __ (_) | | __ / _ \ / ___|
4
+ | '_ \ | | | |/ / | | | | \___ \
5
+ | | | | | | | < | |_| | ___) |
6
+ |_| |_| |_| |_|\_\ \___/ |____/
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }