nikos 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +45 -9
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +49 -1
  4. package/dist/commands/approve.d.ts +16 -0
  5. package/dist/commands/approve.js +120 -0
  6. package/dist/commands/ci.d.ts +18 -0
  7. package/dist/commands/ci.js +123 -0
  8. package/dist/commands/clear.d.ts +14 -0
  9. package/dist/commands/decide.d.ts +11 -0
  10. package/dist/commands/decide.js +3 -3
  11. package/dist/commands/decisions.d.ts +14 -0
  12. package/dist/commands/decisions.js +2 -2
  13. package/dist/commands/doctor.d.ts +11 -0
  14. package/dist/commands/doctor.js +92 -0
  15. package/dist/commands/export.d.ts +48 -0
  16. package/dist/commands/export.js +164 -0
  17. package/dist/commands/init.d.ts +16 -0
  18. package/dist/commands/init.js +8 -12
  19. package/dist/commands/join.d.ts +17 -0
  20. package/dist/commands/join.js +91 -0
  21. package/dist/commands/map.d.ts +65 -0
  22. package/dist/commands/map.js +202 -25
  23. package/dist/commands/migrate.d.ts +10 -0
  24. package/dist/commands/migrate.js +105 -0
  25. package/dist/commands/next.d.ts +19 -0
  26. package/dist/commands/next.js +29 -5
  27. package/dist/commands/plan.d.ts +8 -0
  28. package/dist/commands/plan.js +9 -8
  29. package/dist/commands/release.d.ts +66 -0
  30. package/dist/commands/release.js +75 -6
  31. package/dist/commands/show.d.ts +3 -0
  32. package/dist/commands/show.js +26 -4
  33. package/dist/commands/start.d.ts +28 -0
  34. package/dist/commands/start.js +138 -0
  35. package/dist/commands/state.d.ts +27 -0
  36. package/dist/commands/state.js +40 -22
  37. package/dist/commands/step.d.ts +17 -0
  38. package/dist/commands/step.js +38 -2
  39. package/dist/commands/story.d.ts +21 -0
  40. package/dist/commands/story.js +7 -10
  41. package/dist/commands/suggest.d.ts +16 -0
  42. package/dist/commands/suggest.js +140 -0
  43. package/dist/commands/tracker.d.ts +60 -0
  44. package/dist/commands/tracker.js +185 -0
  45. package/dist/commands/validate.d.ts +15 -0
  46. package/dist/commands/validate.js +149 -14
  47. package/dist/commands/verify.d.ts +20 -0
  48. package/dist/commands/video.d.ts +33 -0
  49. package/dist/commands/video.js +4 -3
  50. package/dist/index.d.ts +20 -0
  51. package/dist/index.js +24 -0
  52. package/dist/lib/approvals.d.ts +29 -0
  53. package/dist/lib/approvals.js +28 -0
  54. package/dist/lib/bets.d.ts +21 -0
  55. package/dist/lib/bets.js +9 -2
  56. package/dist/lib/frontmatter.d.ts +5 -0
  57. package/dist/lib/project.d.ts +25 -0
  58. package/dist/lib/project.js +25 -7
  59. package/dist/lib/settings.d.ts +29 -0
  60. package/dist/lib/settings.js +110 -0
  61. package/dist/lib/state-files.d.ts +44 -0
  62. package/dist/lib/state-files.js +102 -0
  63. package/dist/lib/store.d.ts +44 -0
  64. package/dist/lib/store.js +97 -0
  65. package/dist/lint/constitution.d.ts +5 -0
  66. package/dist/lint/gherkin.d.ts +12 -0
  67. package/dist/lint/gherkin.js +2 -2
  68. package/dist/lint/portable.d.ts +10 -0
  69. package/dist/mcp/server.d.ts +4 -0
  70. package/dist/mcp/server.js +239 -0
  71. package/dist/planning/bets.d.ts +31 -0
  72. package/dist/planning/cli.d.ts +8 -0
  73. package/dist/planning/cli.js +17 -1
  74. package/dist/planning/delivery.d.ts +12 -0
  75. package/dist/planning/delivery.js +8 -5
  76. package/dist/planning/krs.d.ts +28 -0
  77. package/dist/planning/model.d.ts +55 -0
  78. package/dist/planning/model.js +10 -10
  79. package/dist/planning/rollout.d.ts +72 -0
  80. package/dist/planning/rollout.js +206 -0
  81. package/dist/planning/server.d.ts +113 -0
  82. package/dist/planning/server.js +9 -6
  83. package/dist/planning/ui.d.ts +7 -0
  84. package/dist/planning/ui.js +26 -1
  85. package/dist/planning/upstream.d.ts +73 -0
  86. package/dist/planning/upstream.js +78 -20
  87. package/dist/verify/detect.d.ts +3 -0
  88. package/docs/format.md +110 -1
  89. package/docs/gherkin.md +3 -0
  90. package/docs/install.md +39 -0
  91. package/docs/library.md +53 -0
  92. package/docs/planning.md +51 -5
  93. package/docs/settings.md +112 -0
  94. package/package.json +12 -2
  95. package/schemas/approvals.schema.json +27 -0
  96. package/schemas/planning/kr.schema.json +1 -1
  97. package/schemas/settings.schema.json +40 -0
  98. package/schemas/state.schema.json +6 -2
  99. package/schemas/storymap.schema.json +13 -0
  100. package/skills/nikos-mode/SKILL.md +22 -1
  101. package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
  102. package/skills/nikos-mode/playbooks/company.md +16 -0
  103. package/skills/nikos-mode/playbooks/index.md +4 -0
  104. package/skills/nikos-mode/playbooks/map-existing.md +14 -0
  105. package/skills/nikos-mode/playbooks/start.md +12 -0
  106. package/skills/nikos-mode/playbooks/tracker.md +14 -0
  107. package/skills/nikos-mode/trackers/github.md +18 -0
  108. package/skills/nikos-mode/trackers/gitlab.md +17 -0
  109. package/skills/nikos-mode/trackers/jira.md +19 -0
  110. package/skills/nikos-mode/trackers/linear.md +17 -0
  111. package/skills/verify/SKILL.md +9 -0
@@ -0,0 +1,66 @@
1
+ import { type ValidationResult } from "./validate.js";
2
+ /** Add the next numbered release, with no stories yet. */
3
+ export declare function addRelease(input: {
4
+ title: string;
5
+ goal?: string;
6
+ }, dir: string): {
7
+ number: number;
8
+ validation: ValidationResult;
9
+ };
10
+ /** Change a release's title and/or goal. An empty goal removes it. */
11
+ export declare function renameRelease(input: {
12
+ number: number;
13
+ title?: string;
14
+ goal?: string;
15
+ }, dir: string): {
16
+ number: number;
17
+ validation: ValidationResult;
18
+ };
19
+ /** Move a story to another release (to the end of its build order), keeping its feature tag in step. */
20
+ export declare function moveStory(input: {
21
+ id: string;
22
+ to: number;
23
+ }, dir: string): {
24
+ id: string;
25
+ from: number;
26
+ to: number;
27
+ validation: ValidationResult;
28
+ };
29
+ /** Remove an empty release and renumber the ones after it, retagging their stories. */
30
+ export declare function removeRelease(input: {
31
+ number: number;
32
+ }, dir: string): {
33
+ number: number;
34
+ title: string;
35
+ validation: ValidationResult;
36
+ };
37
+ /** Move a release to a new position and renumber them all from 1, retagging every story that moved. */
38
+ export declare function reorderRelease(input: {
39
+ number: number;
40
+ to: number;
41
+ }, dir: string): {
42
+ order: {
43
+ number: number;
44
+ title: string;
45
+ }[];
46
+ validation: ValidationResult;
47
+ };
48
+ export interface ReleaseCheck {
49
+ number: number;
50
+ title: string;
51
+ ready: boolean;
52
+ /** "4 of 6 done · 2 approved · 1 cancelled" */
53
+ summary: string;
54
+ /** What stands between the release and shipping, one line per problem. */
55
+ gaps: string[];
56
+ }
57
+ /**
58
+ * Is a release ready to ship? Every story that isn't cancelled must be done (or approved), not
59
+ * blocked, have scenarios, and still match the feature it was done against; its dependencies
60
+ * outside the release must be done too. With run, each done story's scenarios run again.
61
+ */
62
+ export declare function checkRelease(input: {
63
+ number: number;
64
+ run?: boolean;
65
+ }, dir: string): ReleaseCheck;
66
+ export declare function runRelease(args: string[]): number;
@@ -1,7 +1,11 @@
1
- import { writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { store } from "../lib/store.js";
2
+ import { dirname, join } from "node:path";
3
3
  import { isMap, isSeq, parseDocument } from "yaml";
4
- import { featureStoryIds, findIntentDir, read, setFeatureTag, walk } from "../lib/project.js";
4
+ import { featureStoryIds, findIntentDir, isDone, read, setFeatureTag, walk } from "../lib/project.js";
5
+ import { storyMapOf } from "./map.js";
6
+ import { resolveSettings } from "../lib/settings.js";
7
+ import { failingScenarios } from "./state.js";
8
+ import { verify } from "./verify.js";
5
9
  import { validate } from "./validate.js";
6
10
  const yamlOpts = { lineWidth: 0, flowCollectionPadding: false };
7
11
  function open(dir) {
@@ -33,7 +37,7 @@ function retag(intentDir, moved) {
33
37
  const text = read(file);
34
38
  const id = featureStoryIds(text)[0];
35
39
  if (id && moved.has(id))
36
- writeFileSync(file, setFeatureTag(text, "release", String(moved.get(id)), id));
40
+ store().write(file, setFeatureTag(text, "release", String(moved.get(id)), id));
37
41
  }
38
42
  }
39
43
  /** Number the releases 1, 2, 3… in list order, retagging the stories of any that changed. */
@@ -49,7 +53,7 @@ function renumber(o) {
49
53
  retag(o.intentDir, moved);
50
54
  }
51
55
  function save(o) {
52
- writeFileSync(o.mapPath, o.map.toString(yamlOpts));
56
+ store().write(o.mapPath, o.map.toString(yamlOpts));
53
57
  return validate(o.intentDir);
54
58
  }
55
59
  const cleanTitle = (title) => {
@@ -146,12 +150,70 @@ export function reorderRelease(input, dir) {
146
150
  renumber(o);
147
151
  return { order: items.map((i) => ({ number: numberOf(i), title: String(i.get("title")) })), validation: save(o) };
148
152
  }
153
+ const STATUS_WORD = { todo: "to do", in_progress: "in progress", needs_rework: "in rework" };
154
+ /**
155
+ * Is a release ready to ship? Every story that isn't cancelled must be done (or approved), not
156
+ * blocked, have scenarios, and still match the feature it was done against; its dependencies
157
+ * outside the release must be done too. With run, each done story's scenarios run again.
158
+ */
159
+ export function checkRelease(input, dir) {
160
+ const map = storyMapOf(dir);
161
+ const release = map.releases.find((r) => r.number === input.number);
162
+ if (!release)
163
+ throw new Error(`There is no release ${input.number}. Releases: ${map.releases.map((r) => r.number).join(", ") || "none"}`);
164
+ const inRelease = new Set(release.stories.map((s) => s.id));
165
+ // approval.policy: required-for-release makes a done story without a sign-off a gap.
166
+ const needApproval = resolveSettings(dirname(findIntentDir(dir)))["approval.policy"].value === "required-for-release";
167
+ const live = release.stories.filter((s) => s.status !== "cancelled");
168
+ const gaps = [];
169
+ for (const s of live) {
170
+ if (!isDone(s.status))
171
+ gaps.push(`${s.id} is ${STATUS_WORD[s.status] ?? s.status}`);
172
+ if (s.blocked)
173
+ gaps.push(`${s.id} is blocked`);
174
+ if (!s.criteria.length)
175
+ gaps.push(`${s.id} has no scenarios`);
176
+ if (s.specChanged)
177
+ gaps.push(`${s.id}'s feature changed after it was ${s.status}`);
178
+ if (needApproval && s.status === "done" && !s.specChanged)
179
+ gaps.push(`${s.id} isn't approved`);
180
+ const outside = s.waitingOn.filter((d) => !inRelease.has(d));
181
+ if (outside.length)
182
+ gaps.push(`${s.id} depends on ${outside.join(", ")}, which ${outside.length === 1 ? "isn't" : "aren't"} done`);
183
+ }
184
+ if (input.run) {
185
+ const root = dirname(findIntentDir(dir));
186
+ for (const s of live.filter((x) => isDone(x.status) && x.criteria.length)) {
187
+ let run;
188
+ try {
189
+ run = verify(root, s.id);
190
+ }
191
+ catch (err) {
192
+ gaps.push(`${s.id}'s scenarios couldn't run: ${err.message}`);
193
+ continue;
194
+ }
195
+ if (run.status !== 0) {
196
+ const failing = failingScenarios(run.output);
197
+ gaps.push(`${s.id}'s scenarios fail${failing.length ? `: ${failing.join(", ")}` : ""}`);
198
+ }
199
+ }
200
+ }
201
+ const n = (f) => live.filter(f).length;
202
+ const cancelled = release.stories.length - live.length;
203
+ const summary = [
204
+ `${n((s) => isDone(s.status))} of ${live.length} done`,
205
+ ...(n((s) => s.status === "approved") ? [`${n((s) => s.status === "approved")} approved`] : []),
206
+ ...(cancelled ? [`${cancelled} cancelled`] : []),
207
+ ].join(" · ");
208
+ return { number: release.number, title: release.title, ready: live.length > 0 && gaps.length === 0, summary, gaps: live.length ? gaps : ["it has no stories"] };
209
+ }
149
210
  const USAGE = `Usage:
150
211
  nikos release add "<title>" [--goal "<goal>"]
151
212
  nikos release rename <n> ["<title>"] [--goal "<goal>"] (--goal "" removes it)
152
213
  nikos release move <story-id> <n>
153
214
  nikos release reorder <n> <position>
154
- nikos release remove <n> (only an empty release)`;
215
+ nikos release remove <n> (only an empty release)
216
+ nikos release check <n> [--run] (--run reruns each done story's scenarios)`;
155
217
  function report(v) {
156
218
  if (v.errors.length)
157
219
  console.log(`\n! intent/ now has ${v.errors.length} ${v.errors.length === 1 ? "problem" : "problems"}; run nikos validate to see them.`);
@@ -185,6 +247,13 @@ export function runRelease(args) {
185
247
  console.log(`Releases are now:\n${r.order.map((x) => ` ${x.number}. ${x.title}`).join("\n")}`);
186
248
  report(r.validation);
187
249
  }
250
+ else if (sub === "check" && positional[0]) {
251
+ const r = checkRelease({ number: Number(positional[0]), run: rest.includes("--run") }, dir);
252
+ console.log(`Release ${r.number} · ${r.title}: ${r.ready ? "ready" : "not ready"} — ${r.summary}`);
253
+ for (const g of r.gaps)
254
+ console.log(` ✗ ${g}`);
255
+ return r.ready ? 0 : 1;
256
+ }
188
257
  else if (sub === "remove" && positional[0]) {
189
258
  const r = removeRelease({ number: Number(positional[0]) }, dir);
190
259
  console.log(`Removed release ${r.number}: ${r.title}`);
@@ -0,0 +1,3 @@
1
+ /** Everything the intent/ folder says about one story, in plain text. */
2
+ export declare function show(story: string, dir: string): string;
3
+ export declare function runShow(args: string[]): number;
@@ -1,14 +1,16 @@
1
- import { existsSync } from "node:fs";
1
+ import { store } from "../lib/store.js";
2
+ import { loadStates } from "../lib/state-files.js";
2
3
  import { join } from "node:path";
3
4
  import { parse } from "yaml";
4
- import { STORY_ID, findIntentDir, read, rel } from "../lib/project.js";
5
+ import { approvalLapse, loadApprovals } from "../lib/approvals.js";
6
+ import { STORY_ID, featureTags, findIntentDir, isDone, read, rel, specHash } from "../lib/project.js";
5
7
  import { parseFeature, scenariosOf } from "../lint/gherkin.js";
6
8
  import { featureFor } from "./clear.js";
7
9
  import { planVersions } from "./plan.js";
8
10
  import { parseFrontMatter } from "../lib/frontmatter.js";
9
11
  import { BET_ID, betsFor } from "../lib/bets.js";
10
12
  import { decisionEntries } from "./decisions.js";
11
- const yamlFile = (path) => (existsSync(path) ? parse(read(path)) : undefined);
13
+ const yamlFile = (path) => (store().exists(path) ? parse(read(path)) : undefined);
12
14
  /** Everything the intent/ folder says about one story, in plain text. */
13
15
  export function show(story, dir) {
14
16
  if (!STORY_ID.test(story))
@@ -25,13 +27,24 @@ export function show(story, dir) {
25
27
  const effort = tags.find((t) => t.startsWith("effort-"))?.slice(7);
26
28
  const map = yamlFile(join(intentDir, "storymap.yaml"));
27
29
  const release = (map?.releases ?? []).find((r) => (r?.stories ?? []).includes(story));
28
- const entry = yamlFile(join(intentDir, "state.yaml"))?.stories?.[story];
30
+ const states = loadStates(intentDir);
31
+ const entry = states[story];
32
+ const text = read(file);
29
33
  const out = [`${story} — ${feature.name}`];
30
34
  if (entry) {
31
35
  const extra = [entry.claimed_by && `claimed by ${entry.claimed_by}`, entry.updated && `updated ${entry.updated}`].filter(Boolean);
32
36
  out.push(`Status: ${entry.status}${extra.length ? ` (${extra.join(", ")})` : ""}`);
33
37
  if (entry.blockers?.length)
34
38
  out.push(`Blockers: ${entry.blockers.join("; ")}`);
39
+ const approval = loadApprovals(intentDir)[story];
40
+ if (approval) {
41
+ const lapse = approvalLapse(approval, entry, specHash(text));
42
+ out.push(lapse ? `! Approved by ${approval.by} on ${approval.on}, but that no longer holds: ${lapse}` : `Approved by ${approval.by} on ${approval.on}`);
43
+ }
44
+ if (entry.status === "cancelled")
45
+ out.push(`Cancelled: ${entry.cancelled_reason ?? "no reason given"}`);
46
+ if (isDone(entry.status) && entry.spec_hash && entry.spec_hash !== specHash(text))
47
+ out.push(`! The feature changed after it was ${entry.status}: its scenarios no longer prove it`);
35
48
  }
36
49
  else {
37
50
  out.push("Status: not in state.yaml");
@@ -39,6 +52,15 @@ export function show(story, dir) {
39
52
  out.push([release ? `Release ${release.number}${release.title ? ` (${release.title})` : ""}` : "Not in a release", priority, effort && `effort ${effort}`]
40
53
  .filter(Boolean)
41
54
  .join(" · "));
55
+ const deps = featureTags(text, "depends");
56
+ if (deps.length)
57
+ out.push(`Depends on: ${deps.map((d) => `${d} (${states[d]?.status ?? "unknown"})`).join(", ")}`);
58
+ const personas = featureTags(text, "persona");
59
+ if (personas.length)
60
+ out.push(`For: ${personas.join(", ")}`);
61
+ const type = featureTags(text, "type")[0];
62
+ if (type)
63
+ out.push(`Type: ${type}`);
42
64
  out.push("", `Feature: intent/${rel(intentDir, file)}`);
43
65
  for (const line of feature.description.split("\n").map((l) => l.trim()).filter(Boolean))
44
66
  out.push(` ${line}`);
@@ -0,0 +1,28 @@
1
+ import { type Suggestion } from "./suggest.js";
2
+ export interface StartResult {
3
+ /** One line per thing start did, or found already done. */
4
+ done: string[];
5
+ /** Things it couldn't do on its own, for the agent to ask about. */
6
+ ask: string[];
7
+ next: Suggestion;
8
+ }
9
+ export declare function start(root: string, opts?: {
10
+ name?: string;
11
+ key?: string;
12
+ }): StartResult;
13
+ export interface CompanyResult {
14
+ created: string[];
15
+ skipped: string[];
16
+ /** The line each department or team runs (or asks their agent to) to join. */
17
+ join: string;
18
+ }
19
+ /** Write a company plan repo: plan.yaml, settings every level inherits, the departments, and a README on joining. */
20
+ export declare function companyInit(dir: string, opts: {
21
+ name: string;
22
+ period?: string;
23
+ departments?: string[];
24
+ repo?: string;
25
+ host?: string;
26
+ }): CompanyResult;
27
+ export declare function runStart(args: string[]): number;
28
+ export declare function runCompany(args: string[]): number;
@@ -0,0 +1,138 @@
1
+ // `nikos start` and `nikos company init`: the first run, for a team and for a company.
2
+ // start sets a repo up with sensible defaults: intent/ (name and key from the folder), a
3
+ // Cucumber runner for its stack, and CI for its host, each only when it's missing.
4
+ // company init writes a company plan repo that departments and teams join.
5
+ import { existsSync } from "node:fs";
6
+ import { basename, join, resolve } from "node:path";
7
+ import { stringify } from "yaml";
8
+ import { store } from "../lib/store.js";
9
+ import { detectStack } from "../verify/detect.js";
10
+ import { detectHost, setupCi } from "./ci.js";
11
+ import { init } from "./init.js";
12
+ import { suggest } from "./suggest.js";
13
+ import { setupVerify } from "./verify.js";
14
+ export function start(root, opts = {}) {
15
+ const done = [];
16
+ const ask = [];
17
+ if (store().exists(join(root, "intent", "storymap.yaml")))
18
+ done.push("intent/ is already set up");
19
+ else {
20
+ const r = init(root, opts);
21
+ const map = r.validation;
22
+ done.push(`Created intent/ for ${opts.name ?? basename(resolve(root))} (${map.stories} starter story)`);
23
+ }
24
+ const stack = detectStack(root);
25
+ if (!stack)
26
+ ask.push("Which language is this project in? There's no package.json, pyproject.toml or requirements.txt yet, so there's no scenario runner.");
27
+ else {
28
+ const v = setupVerify(root);
29
+ done.push(v.created.length ? `Set up a Cucumber runner for ${stack} (${v.created.join(", ")}). Install it with: ${v.install}` : `The ${stack} scenario runner is already set up`);
30
+ }
31
+ const host = detectHost(root);
32
+ if (!host)
33
+ ask.push("Is this repo on GitHub or GitLab? There's no origin remote yet, so CI isn't set up.");
34
+ else if (stack) {
35
+ const c = setupCi(root, { host });
36
+ done.push(c.created.length ? `Added CI for ${host} (${c.created.join(", ")})${c.todo ? `. ${c.todo.split("\n")[0]}` : ""}` : `CI for ${host} is already set up`);
37
+ }
38
+ return { done, ask, next: suggest(root) };
39
+ }
40
+ /** Write a company plan repo: plan.yaml, settings every level inherits, the departments, and a README on joining. */
41
+ export function companyInit(dir, opts) {
42
+ const name = opts.name?.trim();
43
+ if (!name)
44
+ throw new Error("Give the company's name");
45
+ const root = resolve(dir);
46
+ const repo = opts.repo ?? `<owner>/${basename(root)}`;
47
+ const join_ = `npx -y nikos join ${repo}${opts.host && opts.host !== "github.com" ? ` --host ${opts.host}` : ""}`;
48
+ const files = {
49
+ "planning/plan.yaml": `# ${name}'s plan. Departments and teams align their key results and bets to it.\n${stringify({ name, period: opts.period ?? "Set the period, e.g. Q1 2027", level: "company", pillars: [] })}`,
50
+ "nikos.settings.yaml": `# Settings every department, group and team that joins this plan inherits.
51
+ # Lock a key (locked: [approval.policy]) so the levels below can't change it.
52
+ format_version: 1
53
+ approval:
54
+ policy: optional # off | optional | required-for-release
55
+ tracker:
56
+ system: none # jira | linear | github | gitlab | …: connect it with the tracker playbook
57
+ `,
58
+ "planning/downstream.yaml": `# The departments and teams that plan against this one: name, and their repo once they have one.\n${stringify((opts.departments ?? []).map((d) => ({ name: d.trim() })).filter((d) => d.name)) || "[]\n"}`,
59
+ "README.md": `# ${name} plan
60
+
61
+ The company's objectives, key results and bets, planned with [nikOS](https://nikos.tools).
62
+ Departments and teams plan against it from their own repos, and inherit the settings in
63
+ \`nikos.settings.yaml\`.
64
+
65
+ ## Join this plan
66
+
67
+ From a department's or team's repo, ask your agent to "join our company plan", or run:
68
+
69
+ \`\`\`
70
+ ${join_}
71
+ \`\`\`
72
+
73
+ It links your repo, fetches a read-only copy of this plan, and shows the settings you inherit.
74
+ Nothing in your repo moves.
75
+ `,
76
+ };
77
+ const created = [];
78
+ const skipped = [];
79
+ for (const [rel, text] of Object.entries(files)) {
80
+ const path = join(root, rel);
81
+ if (store().exists(path))
82
+ skipped.push(rel);
83
+ else {
84
+ store().write(path, text, { create: true });
85
+ created.push(rel);
86
+ }
87
+ }
88
+ for (const sub of ["krs", "bets"]) {
89
+ const keep = join(root, "planning", sub, ".gitkeep");
90
+ if (!store().exists(join(root, "planning", sub)))
91
+ store().write(keep, "");
92
+ }
93
+ return { created, skipped, join: join_ };
94
+ }
95
+ export function runStart(args) {
96
+ const flag = (n) => {
97
+ const i = args.indexOf(`--${n}`);
98
+ return i >= 0 ? args[i + 1] : undefined;
99
+ };
100
+ try {
101
+ const r = start(flag("dir") ?? process.cwd(), { name: flag("name"), key: flag("key") });
102
+ for (const d of r.done)
103
+ console.log(`✓ ${d}`);
104
+ for (const a of r.ask)
105
+ console.log(`? ${a}`);
106
+ console.log(`\n${r.next.summary}\n${r.next.options.map((o, n) => ` ${n + 1}. ${o.label}`).join("\n")}`);
107
+ return 0;
108
+ }
109
+ catch (err) {
110
+ console.error(`✗ ${err.message}`);
111
+ return 1;
112
+ }
113
+ }
114
+ export function runCompany(args) {
115
+ const [sub, ...rest] = args;
116
+ const flag = (n) => {
117
+ const i = rest.indexOf(`--${n}`);
118
+ return i >= 0 ? rest[i + 1] : undefined;
119
+ };
120
+ if (sub !== "init" || !flag("name")) {
121
+ console.error('Usage: nikos company init --name "<company>" [--period "Q1 2027"] [--departments "Payments, Growth"] [--repo owner/company-plan] [--host gitlab.com] [--dir <path>]');
122
+ return 1;
123
+ }
124
+ try {
125
+ const dir = flag("dir") ?? process.cwd();
126
+ const r = companyInit(dir, { name: flag("name"), period: flag("period"), departments: flag("departments")?.split(","), repo: flag("repo"), host: flag("host") });
127
+ if (r.created.length)
128
+ console.log(`Created:\n${r.created.map((f) => ` + ${f}`).join("\n")}`);
129
+ if (r.skipped.length)
130
+ console.log(`Skipped (already there):\n${r.skipped.map((f) => ` = ${f}`).join("\n")}`);
131
+ console.log(`\nEach department or team joins with:\n ${r.join}${existsSync(join(dir, ".git")) ? "" : "\n\nThis folder isn't a git repo yet: create the repo on GitHub or GitLab and push it, so others can join."}`);
132
+ return 0;
133
+ }
134
+ catch (err) {
135
+ console.error(`✗ ${err.message}`);
136
+ return 1;
137
+ }
138
+ }
@@ -0,0 +1,27 @@
1
+ export type Status = "todo" | "in_progress" | "needs_rework" | "done" | "cancelled";
2
+ export interface StateResult {
3
+ story: string;
4
+ from: Status;
5
+ to: Status;
6
+ /** Scenarios that stopped a move to done. */
7
+ failing: string[];
8
+ /** The runner's output when it gated the move. */
9
+ output?: string;
10
+ }
11
+ /** Scenario names from a cucumber-js or behave run that didn't pass. */
12
+ export declare function failingScenarios(output: string): string[];
13
+ export declare class StateError extends Error {
14
+ readonly result?: StateResult | undefined;
15
+ constructor(message: string, result?: StateResult | undefined);
16
+ }
17
+ export interface StateOptions {
18
+ dir?: string;
19
+ by?: string;
20
+ date?: string;
21
+ /** Why the story is cancelled; required for a move to cancelled. */
22
+ reason?: string;
23
+ /** The time recorded as done_at; tests pin it. */
24
+ now?: string;
25
+ }
26
+ export declare function setState(story: string, to: Status, opts?: StateOptions): StateResult;
27
+ export declare function runState(args: string[]): number;
@@ -1,15 +1,19 @@
1
- import { writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { parseDocument } from "yaml";
4
- import { STORY_ID, findIntentDir, read } from "../lib/project.js";
1
+ import { dirname } from "node:path";
2
+ import { noEntry, storyState, updateStory } from "../lib/state-files.js";
3
+ import { STORY_ID, findIntentDir, read, specHash } from "../lib/project.js";
4
+ import { featureFor } from "./clear.js";
5
5
  import { formatResult, validate } from "./validate.js";
6
6
  import { verify } from "./verify.js";
7
- /** The moves docs/format.md allows. `done` also needs the story's scenarios to pass. */
7
+ /**
8
+ * The moves docs/format.md allows. `done` also needs the story's scenarios to pass; `cancelled`
9
+ * needs a reason. Approval isn't a status: it's a person's entry in approvals.yaml.
10
+ */
8
11
  const MOVES = {
9
- todo: ["in_progress"],
10
- in_progress: ["needs_rework", "done"],
11
- needs_rework: ["in_progress"],
12
+ todo: ["in_progress", "cancelled"],
13
+ in_progress: ["needs_rework", "done", "cancelled"],
14
+ needs_rework: ["in_progress", "cancelled"],
12
15
  done: ["needs_rework"],
16
+ cancelled: ["todo"],
13
17
  };
14
18
  /** Scenario names from a cucumber-js or behave run that didn't pass. */
15
19
  export function failingScenarios(output) {
@@ -37,15 +41,16 @@ export class StateError extends Error {
37
41
  export function setState(story, to, opts = {}) {
38
42
  if (!STORY_ID.test(story))
39
43
  throw new StateError(`'${story}' is not a story id (expected e.g. HT-1)`);
44
+ if (to === "approved")
45
+ throw new StateError(`Approval isn't a status: a person approves ${story} with nikos approve, or by reviewing a change to intent/approvals.yaml`);
40
46
  if (!(to in MOVES))
41
- throw new StateError(`'${to}' is not a status (todo, in_progress, needs_rework, done)`);
47
+ throw new StateError(`'${to}' is not a status (${Object.keys(MOVES).join(", ")})`);
48
+ if (to === "cancelled" && !opts.reason?.trim())
49
+ throw new StateError(`Say why ${story} is cancelled: --reason "<why>"`);
42
50
  const intentDir = findIntentDir(opts.dir ?? process.cwd());
43
- const path = join(intentDir, "state.yaml");
44
- // A Document keeps comments and the order of everything we don't touch.
45
- const doc = parseDocument(read(path));
46
- const from = doc.getIn(["stories", story, "status"]);
51
+ const from = storyState(intentDir, story)?.status;
47
52
  if (!from)
48
- throw new StateError(`${story} has no entry in state.yaml`);
53
+ throw new StateError(noEntry(intentDir, story));
49
54
  if (!MOVES[from]?.includes(to)) {
50
55
  throw new StateError(`${story} can't move from ${from} to ${to}. From ${from} it can move to: ${MOVES[from].join(", ")}`);
51
56
  }
@@ -66,18 +71,31 @@ export function setState(story, to, opts = {}) {
66
71
  }
67
72
  result.output = run.output;
68
73
  }
69
- doc.setIn(["stories", story, "status"], to);
70
- if (opts.by && to === "in_progress")
71
- doc.setIn(["stories", story, "claimed_by"], opts.by);
72
- doc.setIn(["stories", story, "updated"], opts.date ?? new Date().toISOString().slice(0, 10));
73
- // No padding keeps "[A-1, A-2]" as written instead of "[ A-1, A-2 ]".
74
- writeFileSync(path, doc.toString({ flowCollectionPadding: false }));
74
+ const feature = featureFor(story, dirname(intentDir));
75
+ // A Document keeps comments and the order of everything we don't touch.
76
+ updateStory(intentDir, story, (doc, entry) => {
77
+ doc.setIn([...entry, "status"], to);
78
+ if (opts.by && to === "in_progress")
79
+ doc.setIn([...entry, "claimed_by"], opts.by);
80
+ if (to === "done") {
81
+ // What the scenarios proved: validate warns if the feature changes afterwards. done_at
82
+ // marks this done, so an approval of an earlier done doesn't carry over.
83
+ if (feature)
84
+ doc.setIn([...entry, "spec_hash"], specHash(read(feature)));
85
+ doc.setIn([...entry, "done_at"], opts.now ?? new Date().toISOString());
86
+ }
87
+ if (to === "cancelled")
88
+ doc.setIn([...entry, "cancelled_reason"], opts.reason.trim());
89
+ else
90
+ doc.deleteIn([...entry, "cancelled_reason"]);
91
+ doc.setIn([...entry, "updated"], opts.date ?? new Date().toISOString().slice(0, 10));
92
+ });
75
93
  return result;
76
94
  }
77
95
  export function runState(args) {
78
96
  const [sub, story, to, ...rest] = args;
79
97
  if (sub !== "set" || !story || !to) {
80
- console.error("Usage: nikos state set <story-id> <todo|in_progress|needs_rework|done> [--by <name>] [--dir <path>]");
98
+ console.error("Usage: nikos state set <story-id> <todo|in_progress|needs_rework|done|cancelled> [--by <name>] [--reason <why>] [--dir <path>]");
81
99
  return 1;
82
100
  }
83
101
  const opt = (n) => {
@@ -85,7 +103,7 @@ export function runState(args) {
85
103
  return i >= 0 ? rest[i + 1] : undefined;
86
104
  };
87
105
  try {
88
- const r = setState(story, to, { dir: opt("dir"), by: opt("by") });
106
+ const r = setState(story, to, { dir: opt("dir"), by: opt("by"), reason: opt("reason") });
89
107
  console.log(`${r.story}: ${r.from} → ${r.to}`);
90
108
  }
91
109
  catch (err) {
@@ -0,0 +1,17 @@
1
+ import { type ValidationResult } from "./validate.js";
2
+ /** Add a step to an activity in storymap.yaml. Its id is the title's slug unless given. */
3
+ export declare function newStep(input: {
4
+ title: string;
5
+ activity: string;
6
+ id?: string;
7
+ }, dir: string): {
8
+ id: string;
9
+ validation: ValidationResult;
10
+ };
11
+ /** Add an activity to the story map, e.g. an area of an existing product or an imported epic. Returns its id; an existing one is left as it is. */
12
+ export declare function newActivity(input: {
13
+ title: string;
14
+ id?: string;
15
+ }, dir: string): string;
16
+ export declare function runActivity(args: string[]): number;
17
+ export declare function runStep(args: string[]): number;
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { store } from "../lib/store.js";
2
2
  import { join } from "node:path";
3
3
  import { isMap, isSeq, parseDocument } from "yaml";
4
4
  import { findIntentDir, read } from "../lib/project.js";
@@ -33,9 +33,45 @@ export function newStep(input, dir) {
33
33
  steps.add(map.createNode({ id, title }));
34
34
  else
35
35
  activity.set("steps", map.createNode([{ id, title }]));
36
- writeFileSync(mapPath, map.toString(yamlOpts));
36
+ store().write(mapPath, map.toString(yamlOpts));
37
37
  return { id, validation: validate(intentDir) };
38
38
  }
39
+ /** Add an activity to the story map, e.g. an area of an existing product or an imported epic. Returns its id; an existing one is left as it is. */
40
+ export function newActivity(input, dir) {
41
+ const title = input.title?.trim();
42
+ if (!title)
43
+ throw new Error("An activity needs a title");
44
+ const intentDir = findIntentDir(dir);
45
+ const mapPath = join(intentDir, "storymap.yaml");
46
+ const map = parseDocument(read(mapPath));
47
+ const id = input.id ?? slugify(title);
48
+ const activities = map.get("activities", true);
49
+ if (!isSeq(activities))
50
+ throw new Error("storymap.yaml has no activities list");
51
+ if (activities.items.some((a) => isMap(a) && a.get("id") === id))
52
+ return id;
53
+ activities.add(map.createNode({ id, title }));
54
+ store().write(mapPath, map.toString(yamlOpts));
55
+ return id;
56
+ }
57
+ export function runActivity(args) {
58
+ const [sub, title, ...rest] = args;
59
+ const i = rest.indexOf("--id");
60
+ const d = rest.indexOf("--dir");
61
+ if (sub !== "add" || !title || title.startsWith("--")) {
62
+ console.error('Usage: nikos activity add "<title>" [--id <activity-id>]');
63
+ return 1;
64
+ }
65
+ try {
66
+ const id = newActivity({ title, id: i >= 0 ? rest[i + 1] : undefined }, d >= 0 ? rest[d + 1] : process.cwd());
67
+ console.log(`Activity ${id}: its stories go in intent/features/${id}/`);
68
+ return 0;
69
+ }
70
+ catch (err) {
71
+ console.error(`✗ ${err.message}`);
72
+ return 1;
73
+ }
74
+ }
39
75
  export function runStep(args) {
40
76
  const [sub, title, ...rest] = args;
41
77
  const opt = (n) => {
@@ -0,0 +1,21 @@
1
+ import { type ValidationResult } from "./validate.js";
2
+ declare const PRIORITIES: readonly ["must", "should", "could"];
3
+ export interface StoryInput {
4
+ title: string;
5
+ activity: string;
6
+ /** A step of that activity. */
7
+ step?: string;
8
+ /** Release number; defaults to the last release. */
9
+ release?: number;
10
+ priority?: (typeof PRIORITIES)[number];
11
+ }
12
+ export interface StoryResult {
13
+ id: string;
14
+ release: number;
15
+ feature: string;
16
+ validation: ValidationResult;
17
+ }
18
+ /** Add a story to storymap.yaml and state.yaml, and write its starter feature file. */
19
+ export declare function newStory(input: StoryInput, dir: string): StoryResult;
20
+ export declare function runStory(args: string[]): number;
21
+ export {};