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
@@ -1,19 +1,25 @@
1
1
  import Ajv2020Module from "ajv/dist/2020.js";
2
2
  const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join, dirname, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parse } from "yaml";
7
7
  import { lintFeature } from "../lint/gherkin.js";
8
8
  import { lintConstitution } from "../lint/constitution.js";
9
9
  import { BET_ID, CROSS_REF, planningDir } from "../lib/bets.js";
10
- import { featureStoryIds, featureTag, findIntentDir, read, rel, walk } from "../lib/project.js";
10
+ import { approvalLapse } from "../lib/approvals.js";
11
+ import { store } from "../lib/store.js";
12
+ import { approvalsDir, stateDir, storyFiles } from "../lib/state-files.js";
13
+ import { featureStoryIds, featureTag, featureTags, findIntentDir, isDone, read, rel, specHash, walk } from "../lib/project.js";
11
14
  const here = dirname(fileURLToPath(import.meta.url));
12
15
  // Works from src/ (tests) and dist/ (published): schemas sit at the package root.
13
16
  const schemaDir = [join(here, "../../schemas"), join(here, "../schemas")].find((d) => existsSync(d));
14
17
  const ajv = new Ajv2020({ allErrors: true, strict: false });
15
- const checkStorymap = ajv.compile(JSON.parse(read(join(schemaDir, "storymap.schema.json"))));
16
- const checkState = ajv.compile(JSON.parse(read(join(schemaDir, "state.schema.json"))));
18
+ // The schemas ship with nikOS, so they're read from disk whatever store is in use.
19
+ const schemaFile = (name) => JSON.parse(readFileSync(join(schemaDir, name), "utf8"));
20
+ const checkStorymap = ajv.compile(schemaFile("storymap.schema.json"));
21
+ const checkState = ajv.compile(schemaFile("state.schema.json"));
22
+ const checkApprovals = ajv.compile(schemaFile("approvals.schema.json"));
17
23
  function pointer(e) {
18
24
  return e.instancePath.replace(/^\//, "").split("/").filter(Boolean).join(".") || "(root)";
19
25
  }
@@ -42,7 +48,7 @@ function describe(e, data) {
42
48
  }
43
49
  function loadYaml(dir, name, issues) {
44
50
  const path = join(dir, name);
45
- if (!existsSync(path)) {
51
+ if (!store().exists(path)) {
46
52
  issues.push({ file: name, message: "file is missing", hint: "run `nikos init` to create it" });
47
53
  return undefined;
48
54
  }
@@ -76,9 +82,15 @@ export function validate(start) {
76
82
  // Cross-file checks only when both files parsed into objects.
77
83
  const mapStories = new Map();
78
84
  const activityIds = new Set();
85
+ const personaIds = new Set();
79
86
  // Each step id names its activity; ids are unique across the map so a @step tag is enough.
80
87
  const stepActivity = new Map();
81
88
  if (storymap && typeof storymap === "object") {
89
+ for (const p of storymap.personas ?? []) {
90
+ if (personaIds.has(p?.id))
91
+ errors.push({ file: "storymap.yaml", message: `persona '${p.id}' appears more than once` });
92
+ personaIds.add(p?.id);
93
+ }
82
94
  for (const a of storymap.activities ?? []) {
83
95
  activityIds.add(a?.id);
84
96
  for (const st of a?.steps ?? []) {
@@ -107,13 +119,15 @@ export function validate(start) {
107
119
  }
108
120
  }
109
121
  const constitutionPath = join(dir, "constitution.md");
110
- if (existsSync(constitutionPath)) {
122
+ if (store().exists(constitutionPath)) {
111
123
  for (const issue of lintConstitution(read(constitutionPath))) {
112
124
  (issue.level === "error" ? errors : warnings).push({ file: "constitution.md", message: issue.message, hint: issue.hint });
113
125
  }
114
126
  }
115
127
  // Features
116
128
  const featureIds = new Map();
129
+ const dependsOn = new Map();
130
+ const hashes = new Map();
117
131
  for (const path of walk(join(dir, "features"), ".feature")) {
118
132
  const file = rel(dir, path);
119
133
  const text = read(path);
@@ -148,32 +162,130 @@ export function validate(start) {
148
162
  else if (owner !== activity)
149
163
  errors.push({ file, message: `@step-${step} belongs to activity '${owner}', not '${activity}'` });
150
164
  }
165
+ for (const persona of featureTags(text, "persona")) {
166
+ if (storymap && !personaIds.has(persona))
167
+ errors.push({ file, message: `@persona-${persona} is not a persona in storymap.yaml`, hint: "add it under personas:, or fix the tag" });
168
+ }
169
+ dependsOn.set(id, featureTags(text, "depends"));
170
+ hashes.set(id, specHash(text));
151
171
  const release = featureTag(text, "release");
152
172
  if (release && mapStories.has(id) && Number(release) !== mapStories.get(id)) {
153
173
  warnings.push({ file, message: `is tagged @release-${release} but storymap.yaml puts ${id} in release ${mapStories.get(id)}`, hint: `move it with nikos release move ${id} <n>, which keeps both in step` });
154
174
  }
155
175
  }
156
- const stateStories = (state && typeof state === "object" && state.stories) || {};
157
- if (storymap && state) {
176
+ const stateStories = { ...((state && typeof state === "object" && state.stories) || {}) };
177
+ // Where each story's state is: state.yaml, or its own file under state/.
178
+ const stateFile = (id) => (store().exists(join(stateDir(dir), `${id}.yaml`)) ? `state/${id}.yaml` : "state.yaml");
179
+ for (const f of storyFiles(stateDir(dir))) {
180
+ const file = `state/${f.id}.yaml`;
181
+ const entry = loadYaml(dir, file, errors);
182
+ if (entry === undefined)
183
+ continue;
184
+ if (!/^[A-Z][A-Z0-9]{0,9}-[0-9]+$/.test(f.id)) {
185
+ errors.push({ file, message: `'${f.id}' is not a story id`, hint: "name each file after its story, e.g. state/HT-1.yaml" });
186
+ continue;
187
+ }
188
+ if (f.id in stateStories)
189
+ errors.push({ file, message: `${f.id} is also in state.yaml`, hint: "keep one: nikos migrate per-story moves every story to its own file" });
190
+ const wrapped = { format_version: 1, stories: { [f.id]: entry } };
191
+ if (!checkState(wrapped))
192
+ for (const e of checkState.errors ?? [])
193
+ errors.push({ file, message: describe(e, wrapped).replace(`stories.${f.id}`, f.id) });
194
+ stateStories[f.id] = entry;
195
+ }
196
+ if (storymap && (state || Object.keys(stateStories).length)) {
158
197
  for (const id of mapStories.keys()) {
159
198
  if (!featureIds.has(id)) {
160
199
  errors.push({ file: "storymap.yaml", message: `${id} has no feature file`, hint: `add intent/features/<activity>/<slug>.feature tagged @${id}` });
161
200
  }
162
201
  if (!(id in stateStories)) {
163
- errors.push({ file: "state.yaml", message: `${id} is in storymap.yaml but has no entry under stories`, hint: `add "${id}: { status: todo }"` });
202
+ errors.push(store().isDir(stateDir(dir))
203
+ ? { file: `state/${id}.yaml`, message: `${id} is in storymap.yaml but has no state file`, hint: `add intent/state/${id}.yaml with "status: todo"` }
204
+ : { file: "state.yaml", message: `${id} is in storymap.yaml but has no entry under stories`, hint: `add "${id}: { status: todo }"` });
164
205
  }
165
206
  }
166
207
  for (const id of Object.keys(stateStories)) {
167
208
  if (!mapStories.has(id)) {
168
- errors.push({ file: "state.yaml", message: `stories.${id} is an unknown story id`, hint: "add it to a release in storymap.yaml or remove it" });
209
+ errors.push({ file: stateFile(id), message: stateFile(id) === "state.yaml" ? `stories.${id} is an unknown story id` : `${id} is an unknown story id`, hint: "add it to a release in storymap.yaml or remove it" });
169
210
  }
170
211
  }
171
- for (const id of state.run?.queue ?? []) {
212
+ for (const id of state?.run?.queue ?? []) {
172
213
  if (!mapStories.has(id))
173
214
  errors.push({ file: "state.yaml", message: `run.queue has unknown story id ${id}` });
174
215
  }
216
+ for (const [id, entry] of Object.entries(stateStories)) {
217
+ if (!isDone(entry?.status) || !entry?.spec_hash || !hashes.has(id) || hashes.get(id) === entry.spec_hash)
218
+ continue;
219
+ warnings.push({
220
+ file: featureIds.get(id),
221
+ message: `${id} changed after it was ${entry.status}`,
222
+ hint: `if the change matters, move it back: nikos state set ${id} needs_rework`,
223
+ });
224
+ }
225
+ }
226
+ // Approvals are optional: approvals.yaml (or approvals/<ID>.yaml) appears with the first sign-off.
227
+ const signoffs = [];
228
+ if (store().exists(join(dir, "approvals.yaml"))) {
229
+ const approvals = loadYaml(dir, "approvals.yaml", errors);
230
+ if (approvals !== undefined && !checkApprovals(approvals)) {
231
+ for (const e of checkApprovals.errors ?? []) {
232
+ if (e.keyword === "pattern" && e.propertyName)
233
+ continue;
234
+ errors.push({ file: "approvals.yaml", message: describe(e, approvals) });
235
+ }
236
+ }
237
+ else if (approvals) {
238
+ for (const [id, a] of Object.entries(approvals.approvals ?? {}))
239
+ signoffs.push({ id, a, file: "approvals.yaml", key: `approvals.${id}` });
240
+ }
241
+ }
242
+ for (const f of storyFiles(approvalsDir(dir))) {
243
+ const file = `approvals/${f.id}.yaml`;
244
+ const a = loadYaml(dir, file, errors);
245
+ if (a === undefined)
246
+ continue;
247
+ const wrapped = { format_version: 1, approvals: { [f.id]: a } };
248
+ if (!checkApprovals(wrapped)) {
249
+ for (const e of checkApprovals.errors ?? []) {
250
+ if (e.keyword === "pattern" && e.propertyName)
251
+ continue;
252
+ errors.push({ file, message: describe(e, wrapped).replace(`approvals.${f.id}`, f.id) });
253
+ }
254
+ continue;
255
+ }
256
+ if (signoffs.some((s) => s.id === f.id))
257
+ errors.push({ file, message: `${f.id} is also in approvals.yaml`, hint: "keep one: nikos migrate per-story moves every approval to its own file" });
258
+ signoffs.push({ id: f.id, a, file, key: f.id });
259
+ }
260
+ for (const { id, a, file, key } of signoffs) {
261
+ if (!mapStories.has(id) && !featureIds.has(id)) {
262
+ errors.push({ file, message: `${key} is an unknown story id` });
263
+ continue;
264
+ }
265
+ const lapse = approvalLapse(a, stateStories[id], hashes.get(id));
266
+ if (lapse)
267
+ warnings.push({ file, message: `${id}'s approval no longer holds: ${lapse}`, hint: `once it's done again, a person approves it again (nikos approve ${id}), or remove the entry` });
268
+ }
269
+ // Dependencies: each @depends-<id> names a known story, never itself, never in a loop.
270
+ const statusOf = (id) => stateStories[id]?.status ?? "todo";
271
+ for (const [id, deps] of dependsOn) {
272
+ const file = featureIds.get(id);
273
+ for (const dep of deps) {
274
+ if (dep === id)
275
+ errors.push({ file, message: `${id} depends on itself` });
276
+ else if (!featureIds.has(dep) && !mapStories.has(dep))
277
+ errors.push({ file, message: `@depends-${dep} names a story that doesn't exist` });
278
+ else if (statusOf(dep) === "cancelled" && !isDone(statusOf(id)))
279
+ warnings.push({ file, message: `${id} depends on ${dep}, which is cancelled`, hint: `drop the @depends-${dep} tag, or bring ${dep} back with nikos state set ${dep} todo` });
280
+ else if ((mapStories.get(dep) ?? 0) > (mapStories.get(id) ?? Infinity)) {
281
+ warnings.push({ file, message: `${id} in release ${mapStories.get(id)} depends on ${dep}, which is in a later release (${mapStories.get(dep)})`, hint: `move one of them: nikos release move ${dep} ${mapStories.get(id)}` });
282
+ }
283
+ }
175
284
  }
176
- // Bet links: each names a bet in planning/bets/ or one in another repo (owner/repo#id).
285
+ for (const loop of dependencyLoops(dependsOn)) {
286
+ errors.push({ file: featureIds.get(loop[0]), message: `stories depend on each other in a loop: ${[...loop, loop[0]].join(" → ")}`, hint: "remove one of the @depends tags" });
287
+ }
288
+ // Bet links: each names a bet in planning/bets/ or one in another repo (<repo path>#id).
177
289
  if (storymap && typeof storymap === "object") {
178
290
  const betsDir = join(planningDir(dir), "bets");
179
291
  const check = (where, refs) => {
@@ -183,9 +295,9 @@ export function validate(start) {
183
295
  if (CROSS_REF.test(ref))
184
296
  continue; // another repo's bet: checked for form only, never fetched
185
297
  if (!BET_ID.test(ref)) {
186
- errors.push({ file: "storymap.yaml", message: `${where} names bet '${ref}', which is neither a bet id nor owner/repo#id`, hint: "use a bet id like faster-checkout, or owner/repo#bet-id for another repo's bet" });
298
+ errors.push({ file: "storymap.yaml", message: `${where} names bet '${ref}', which is neither a bet id nor <repo path>#id`, hint: "use a bet id like faster-checkout, or owner/repo#bet-id for another repo's bet" });
187
299
  }
188
- else if (!existsSync(join(betsDir, `${ref}.md`))) {
300
+ else if (!store().exists(join(betsDir, `${ref}.md`))) {
189
301
  errors.push({ file: "storymap.yaml", message: `${where} names bet '${ref}', which has no planning/bets/${ref}.md`, hint: "create it with `nikos planning bet new`, or fix the name" });
190
302
  }
191
303
  }
@@ -200,6 +312,29 @@ export function validate(start) {
200
312
  }
201
313
  return { dir, errors, warnings, stories: mapStories.size, releases: storymap?.releases?.length ?? 0 };
202
314
  }
315
+ /** Each dependency loop once, starting from its first story in file order. */
316
+ function dependencyLoops(deps) {
317
+ const loops = [];
318
+ const seen = new Set();
319
+ const visit = (id, path) => {
320
+ const at = path.indexOf(id);
321
+ if (at >= 0) {
322
+ const loop = path.slice(at);
323
+ if (loop.length > 1 && !loops.some((l) => l.length === loop.length && l.every((x) => loop.includes(x))))
324
+ loops.push(loop);
325
+ return;
326
+ }
327
+ if (seen.has(id))
328
+ return;
329
+ for (const dep of deps.get(id) ?? [])
330
+ if (dep !== id)
331
+ visit(dep, [...path, id]);
332
+ seen.add(id);
333
+ };
334
+ for (const id of deps.keys())
335
+ visit(id, []);
336
+ return loops;
337
+ }
203
338
  export function formatResult(r) {
204
339
  const lines = [];
205
340
  for (const e of r.errors)
@@ -0,0 +1,20 @@
1
+ import { type Stack } from "../verify/detect.js";
2
+ export interface SetupResult {
3
+ stack: Stack;
4
+ created: string[];
5
+ skipped: string[];
6
+ install: string;
7
+ }
8
+ /** Write the runner config and step stubs for the repo's stack. Never overwrites a file. */
9
+ export declare function setupVerify(root: string): SetupResult;
10
+ export interface VerifyResult {
11
+ stack: Stack;
12
+ command: string;
13
+ status: number;
14
+ output: string;
15
+ }
16
+ /** Run the story's scenarios (or all of them) with the repo's Cucumber runner. */
17
+ export declare function verify(root: string, story?: string, opts?: {
18
+ inherit?: boolean;
19
+ }): VerifyResult;
20
+ export declare function runVerify(args: string[]): number;
@@ -0,0 +1,33 @@
1
+ export interface Storyboard {
2
+ product: {
3
+ name: string;
4
+ idea: string;
5
+ };
6
+ users: string;
7
+ problem: string;
8
+ /** One beat per activity with done stories, in story map order, timed in seconds. */
9
+ beats: {
10
+ activity: string;
11
+ from: number;
12
+ to: number;
13
+ stories: {
14
+ id: string;
15
+ title: string;
16
+ outcomes: string[];
17
+ }[];
18
+ }[];
19
+ notBuilt: {
20
+ id: string;
21
+ title: string;
22
+ }[];
23
+ intro: [number, number];
24
+ close: [number, number];
25
+ total: number;
26
+ }
27
+ /** Draft a video storyboard from intent/: only stories marked done, one beat per activity. */
28
+ export declare function storyboard(dir: string): Storyboard;
29
+ /** The brief a person approves before anything renders. */
30
+ export declare function briefOf(sb: Storyboard): string;
31
+ /** Write brief.md and storyboard.js (read by the Video pack's storyboard template) into `out`. */
32
+ export declare function writeVideo(dir: string, out: string): Storyboard;
33
+ export declare function runVideo(args: string[]): number;
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { loadStates } from "../lib/state-files.js";
2
3
  import { join, relative, resolve } from "node:path";
3
4
  import { parse } from "yaml";
4
- import { findIntentDir, featureStoryIds, read, walk } from "../lib/project.js";
5
+ import { findIntentDir, featureStoryIds, isDone, read, walk } from "../lib/project.js";
5
6
  import { parseFeature, scenariosOf } from "../lint/gherkin.js";
6
7
  // Timing: a title card, then each beat holds longer for more stories, then the close.
7
8
  const INTRO = 4.5, BEAT = 2.6, PER_STORY = 1.8, CLOSE = 6;
@@ -23,7 +24,7 @@ export function storyboard(dir) {
23
24
  const intentDir = findIntentDir(dir);
24
25
  const c = constitution(intentDir);
25
26
  const map = parse(read(join(intentDir, "storymap.yaml")));
26
- const state = (parse(read(join(intentDir, "state.yaml"))) ?? {});
27
+ const state = { stories: loadStates(intentDir) };
27
28
  const stories = walk(join(intentDir, "features"), ".feature").flatMap((path) => {
28
29
  const text = read(path);
29
30
  const id = featureStoryIds(text)[0];
@@ -33,7 +34,7 @@ export function storyboard(dir) {
33
34
  const activity = feature.tags.map((t) => t.name).find((t) => t.startsWith("@activity-"))?.slice(10) ?? "";
34
35
  // "AC1 — A new plant shows its next watering day" → "A new plant shows its next watering day"
35
36
  const outcomes = scenariosOf(feature).map((s) => s.name.replace(/^AC\d+\s*[—–-]\s*/, ""));
36
- return [{ id, title: feature.name, activity, outcomes, done: state.stories?.[id]?.status === "done" }];
37
+ return [{ id, title: feature.name, activity, outcomes, done: isDone(state.stories?.[id]?.status) }];
37
38
  });
38
39
  const byNumber = (a, b) => Number(a.id.split("-")[1]) - Number(b.id.split("-")[1]);
39
40
  const done = stories.filter((s) => s.done).sort(byNumber);
@@ -0,0 +1,20 @@
1
+ export { MemoryStore, diskStore, store, withStore, type Store } from "./lib/store.js";
2
+ export { STORY_ID, DONE, isDone, specHash, findIntentDir } from "./lib/project.js";
3
+ export { loadApprovals, approvalLapse, type Approval } from "./lib/approvals.js";
4
+ export { loadStates, storyState, perStory, type StoryState } from "./lib/state-files.js";
5
+ export { validate, formatResult, type Issue, type ValidationResult } from "./commands/validate.js";
6
+ export { init, keyFor, slugify } from "./commands/init.js";
7
+ export { newStory, type StoryInput, type StoryResult } from "./commands/story.js";
8
+ export { newStep } from "./commands/step.js";
9
+ export { addRelease, renameRelease, moveStory, removeRelease, reorderRelease, checkRelease, type ReleaseCheck } from "./commands/release.js";
10
+ export { setState, StateError, type Status, type StateResult } from "./commands/state.js";
11
+ export { next, dependencies, type NextResult } from "./commands/next.js";
12
+ export { suggest, type Option, type Suggestion } from "./commands/suggest.js";
13
+ export { show } from "./commands/show.js";
14
+ export { clear, featureFor } from "./commands/clear.js";
15
+ export { newPlan, planVersions } from "./commands/plan.js";
16
+ export { decide } from "./commands/decide.js";
17
+ export { decisionEntries, findDecisions } from "./commands/decisions.js";
18
+ export { storyMapOf, renderMap, type StoryMap, type MapStory, type MapRelease } from "./commands/map.js";
19
+ export { exportMap, FORMATS, type Format } from "./commands/export.js";
20
+ export { migrate, type Layout, type MigrateResult } from "./commands/migrate.js";
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // nikOS as a library: the rules behind the CLI, for tools that work on a repo's files without a
2
+ // shell. Load the files into a MemoryStore, run commands inside withStore, then commit
3
+ // store.changes(). Running scenarios (done, approve, verify) needs a real checkout, so tools
4
+ // without one leave that to CI.
5
+ export { MemoryStore, diskStore, store, withStore } from "./lib/store.js";
6
+ export { STORY_ID, DONE, isDone, specHash, findIntentDir } from "./lib/project.js";
7
+ export { loadApprovals, approvalLapse } from "./lib/approvals.js";
8
+ export { loadStates, storyState, perStory } from "./lib/state-files.js";
9
+ export { validate, formatResult } from "./commands/validate.js";
10
+ export { init, keyFor, slugify } from "./commands/init.js";
11
+ export { newStory } from "./commands/story.js";
12
+ export { newStep } from "./commands/step.js";
13
+ export { addRelease, renameRelease, moveStory, removeRelease, reorderRelease, checkRelease } from "./commands/release.js";
14
+ export { setState, StateError } from "./commands/state.js";
15
+ export { next, dependencies } from "./commands/next.js";
16
+ export { suggest } from "./commands/suggest.js";
17
+ export { show } from "./commands/show.js";
18
+ export { clear, featureFor } from "./commands/clear.js";
19
+ export { newPlan, planVersions } from "./commands/plan.js";
20
+ export { decide } from "./commands/decide.js";
21
+ export { decisionEntries, findDecisions } from "./commands/decisions.js";
22
+ export { storyMapOf, renderMap } from "./commands/map.js";
23
+ export { exportMap, FORMATS } from "./commands/export.js";
24
+ export { migrate } from "./commands/migrate.js";
@@ -0,0 +1,29 @@
1
+ export interface Approval {
2
+ by: string;
3
+ on: string;
4
+ /** How it was given: typed in a terminal, answered in a chat with an agent, or asked by nikos mcp through the person's app. */
5
+ via?: "terminal" | "chat" | "app" | "tracker";
6
+ /** The feature fingerprint that was approved. */
7
+ spec_hash: string;
8
+ /** Which time the story reached done: going through done again needs a new approval. */
9
+ done_at: string;
10
+ }
11
+ export declare const approvalsPath: (intentDir: string) => string;
12
+ export declare function loadApprovals(intentDir: string): Record<string, Approval>;
13
+ /**
14
+ * Which done a story is on. Stories done before done_at existed fall back to the date of their
15
+ * last move, which stays put while they stay done.
16
+ */
17
+ export declare const doneMark: (state: {
18
+ done_at?: string;
19
+ updated?: string;
20
+ } | undefined) => string;
21
+ /**
22
+ * Whether an approval still holds for a story's current state. It holds while the story is done,
23
+ * since the same done, with the same feature. Returns undefined when it holds, or why not.
24
+ */
25
+ export declare function approvalLapse(approval: Approval, state: {
26
+ status?: string;
27
+ done_at?: string;
28
+ updated?: string;
29
+ } | undefined, currentHash: string | undefined): string | undefined;
@@ -0,0 +1,28 @@
1
+ // Approvals live in intent/approvals.yaml (or intent/approvals/<ID>.yaml, one per story), apart
2
+ // from story state, so a repo can protect them: a CODEOWNERS rule on them means an approval only
3
+ // lands when the right person reviews the PR or merge request. Agents write state all the time;
4
+ // approvals only change on sign-off.
5
+ import { join } from "node:path";
6
+ import { loadApprovalEntries } from "./state-files.js";
7
+ export const approvalsPath = (intentDir) => join(intentDir, "approvals.yaml");
8
+ export function loadApprovals(intentDir) {
9
+ return loadApprovalEntries(intentDir);
10
+ }
11
+ /**
12
+ * Which done a story is on. Stories done before done_at existed fall back to the date of their
13
+ * last move, which stays put while they stay done.
14
+ */
15
+ export const doneMark = (state) => String(state?.done_at ?? state?.updated ?? "unknown");
16
+ /**
17
+ * Whether an approval still holds for a story's current state. It holds while the story is done,
18
+ * since the same done, with the same feature. Returns undefined when it holds, or why not.
19
+ */
20
+ export function approvalLapse(approval, state, currentHash) {
21
+ if (state?.status !== "done")
22
+ return state ? `it's ${state.status} now` : "it has no entry in state";
23
+ if (approval.done_at !== doneMark(state))
24
+ return "it went through done again since";
25
+ if (currentHash && approval.spec_hash !== currentHash)
26
+ return "its feature changed since";
27
+ return undefined;
28
+ }
@@ -0,0 +1,21 @@
1
+ /** A bet in this repo: lowercase words joined by hyphens. */
2
+ export declare const BET_ID: RegExp;
3
+ /**
4
+ * Another repo, as its path on the host: owner/repo on GitHub, or group/subgroup/project on
5
+ * GitLab, with as many groups as it has.
6
+ */
7
+ export declare const REPO_PATH: RegExp;
8
+ /** A bet or key result in another repo: <repo path>#id, e.g. acme/company-plan#d30-retention. */
9
+ export declare const CROSS_REF: RegExp;
10
+ export declare const isBetRef: (ref: string) => boolean;
11
+ /** The repo's planning/ folder, next to intent/. */
12
+ export declare const planningDir: (intentDir: string) => string;
13
+ export interface StoryBets {
14
+ bets: string[];
15
+ from: "story" | "release" | "none";
16
+ release?: number;
17
+ }
18
+ /** The bets a story delivers: its own `story_bets` entry, or else its release's `bets`. */
19
+ export declare function betsFor(map: any, story: string): StoryBets;
20
+ /** Every story that delivers a bet, after story overrides. */
21
+ export declare function storiesFor(map: any, bet: string): string[];
package/dist/lib/bets.js CHANGED
@@ -3,8 +3,15 @@
3
3
  import { dirname, join } from "node:path";
4
4
  /** A bet in this repo: lowercase words joined by hyphens. */
5
5
  export const BET_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6
- /** A bet or key result in another repo: owner/repo#id. */
7
- export const CROSS_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[a-z0-9]+(-[a-z0-9]+)*$/;
6
+ /** One part of a repo path; "." and ".." are never allowed, so a path can't leave .nikos/upstream/. */
7
+ const SEGMENT = String.raw `(?!\.{1,2}(?:/|#|$))[A-Za-z0-9_.-]+`;
8
+ /**
9
+ * Another repo, as its path on the host: owner/repo on GitHub, or group/subgroup/project on
10
+ * GitLab, with as many groups as it has.
11
+ */
12
+ export const REPO_PATH = new RegExp(`^${SEGMENT}(?:/${SEGMENT})+$`);
13
+ /** A bet or key result in another repo: <repo path>#id, e.g. acme/company-plan#d30-retention. */
14
+ export const CROSS_REF = new RegExp(`^${SEGMENT}(?:/${SEGMENT})+#[a-z0-9]+(-[a-z0-9]+)*$`);
8
15
  export const isBetRef = (ref) => BET_ID.test(ref) || CROSS_REF.test(ref);
9
16
  /** The repo's planning/ folder, next to intent/. */
10
17
  export const planningDir = (intentDir) => join(dirname(intentDir), "planning");
@@ -0,0 +1,5 @@
1
+ /** Split a Markdown file into its YAML front matter and body. */
2
+ export declare function parseFrontMatter(text: string): {
3
+ meta: any;
4
+ body: string;
5
+ } | undefined;
@@ -0,0 +1,25 @@
1
+ export declare const STORY_ID: RegExp;
2
+ /** Statuses that count as built. approved is how the map shows done plus a sign-off that holds. */
3
+ export declare const DONE: Set<string>;
4
+ export declare const isDone: (status: string | undefined) => boolean;
5
+ /**
6
+ * A short fingerprint of what a story promises: the feature from `Feature:` on. The tags above
7
+ * it are left out, so moving a story between releases or adding a @depends tag isn't a change.
8
+ */
9
+ export declare function specHash(text: string): string;
10
+ /** Resolve the intent/ folder from a repo root or the folder itself. */
11
+ export declare function findIntentDir(start: string): string;
12
+ export declare function walk(dir: string, ext?: string): string[];
13
+ export declare function read(path: string): string;
14
+ export declare function rel(dir: string, path: string): string;
15
+ /** The value of a `@<name>-<value>` tag above `Feature:`, e.g. featureTag(text, "step"). */
16
+ export declare function featureTag(text: string, name: string): string | undefined;
17
+ /** Every value of a repeatable `@<name>-<value>` tag above `Feature:`, e.g. featureTags(text, "depends"). */
18
+ export declare function featureTags(text: string, name: string): string[];
19
+ /**
20
+ * Set (or with value undefined, remove) a `@<name>-<value>` tag above `Feature:`. An existing tag
21
+ * is replaced where it stands; a new one goes after the tag `after` (e.g. the story id), or last.
22
+ */
23
+ export declare function setFeatureTag(text: string, name: string, value: string | undefined, after?: string): string;
24
+ /** Story id tags on the lines above `Feature:`. */
25
+ export declare function featureStoryIds(text: string): string[];
@@ -1,34 +1,51 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { store } from "./store.js";
2
3
  import { join, relative } from "node:path";
3
4
  export const STORY_ID = /^[A-Z][A-Z0-9]{0,9}-[0-9]+$/;
5
+ /** Statuses that count as built. approved is how the map shows done plus a sign-off that holds. */
6
+ export const DONE = new Set(["done", "approved"]);
7
+ export const isDone = (status) => DONE.has(status ?? "");
8
+ /**
9
+ * A short fingerprint of what a story promises: the feature from `Feature:` on. The tags above
10
+ * it are left out, so moving a story between releases or adding a @depends tag isn't a change.
11
+ */
12
+ export function specHash(text) {
13
+ const at = text.search(/^\s*Feature:/m);
14
+ const spec = (at < 0 ? text : text.slice(at)).replace(/\r\n/g, "\n").split("\n").map((l) => l.trimEnd()).join("\n").trim();
15
+ return createHash("sha256").update(spec).digest("hex").slice(0, 12);
16
+ }
4
17
  /** Resolve the intent/ folder from a repo root or the folder itself. */
5
18
  export function findIntentDir(start) {
6
- if (existsSync(join(start, "storymap.yaml")))
19
+ if (store().exists(join(start, "storymap.yaml")))
7
20
  return start;
8
21
  return join(start, "intent");
9
22
  }
10
23
  export function walk(dir, ext) {
11
- if (!existsSync(dir))
24
+ if (!store().exists(dir))
12
25
  return [];
13
- return readdirSync(dir).flatMap((f) => {
26
+ return store().list(dir).flatMap((f) => {
14
27
  const p = join(dir, f);
15
- if (statSync(p).isDirectory())
28
+ if (store().isDir(p))
16
29
  return walk(p, ext);
17
30
  return !ext || p.endsWith(ext) ? [p] : [];
18
31
  });
19
32
  }
20
33
  export function read(path) {
21
- return readFileSync(path, "utf8");
34
+ return store().read(path);
22
35
  }
23
36
  export function rel(dir, path) {
24
37
  return relative(dir, path) || path;
25
38
  }
26
- /** Story id tags on the lines above `Feature:`. */
27
39
  /** The value of a `@<name>-<value>` tag above `Feature:`, e.g. featureTag(text, "step"). */
28
40
  export function featureTag(text, name) {
29
41
  const head = text.split(/^\s*Feature:/m)[0];
30
42
  return new RegExp(`(?:^|\\s)@${name}-([A-Za-z0-9-]+)`).exec(head)?.[1];
31
43
  }
44
+ /** Every value of a repeatable `@<name>-<value>` tag above `Feature:`, e.g. featureTags(text, "depends"). */
45
+ export function featureTags(text, name) {
46
+ const head = text.split(/^\s*Feature:/m)[0];
47
+ return [...head.matchAll(new RegExp(`(?:^|\\s)@${name}-([A-Za-z0-9-]+)`, "g"))].map((m) => m[1]);
48
+ }
32
49
  /**
33
50
  * Set (or with value undefined, remove) a `@<name>-<value>` tag above `Feature:`. An existing tag
34
51
  * is replaced where it stands; a new one goes after the tag `after` (e.g. the story id), or last.
@@ -61,6 +78,7 @@ export function setFeatureTag(text, name, value, after) {
61
78
  }
62
79
  return lines.join("\n");
63
80
  }
81
+ /** Story id tags on the lines above `Feature:`. */
64
82
  export function featureStoryIds(text) {
65
83
  const ids = [];
66
84
  for (const line of text.split("\n")) {
@@ -0,0 +1,29 @@
1
+ export declare const SETTINGS_FILE = "nikos.settings.yaml";
2
+ export type ApprovalPolicy = "off" | "optional" | "required-for-release";
3
+ export type ApprovalRoute = "app" | "chat" | "terminal";
4
+ /** Every setting nikOS knows, with its default. Keys are dotted paths into the file. */
5
+ export declare const DEFAULTS: Record<string, unknown>;
6
+ export interface Setting {
7
+ value: unknown;
8
+ /** "default", "this repo", or the repo path of the level it came from. */
9
+ from: string;
10
+ /** The level that locked it, if one did. */
11
+ lockedBy?: string;
12
+ }
13
+ export type Resolved = Record<string, Setting>;
14
+ /** A repo's own settings file, parsed and checked. */
15
+ export declare function readSettings(root: string): {
16
+ values: Record<string, unknown>;
17
+ locked: string[];
18
+ };
19
+ /**
20
+ * The settings that apply to a repo: defaults, then each level above it from the top down
21
+ * (through the synced copies in .nikos/upstream/), then the repo's own. A level can't change a
22
+ * value a level above it locked.
23
+ */
24
+ export declare function resolveSettings(root: string): Resolved;
25
+ export declare const settingValue: <T>(root: string, key: string) => T;
26
+ /** Change a setting in this repo's own file, unless a level above locked it. */
27
+ export declare function setSetting(root: string, key: string, value: unknown): string;
28
+ /** The settings as lines: each value, where it came from, and whether it's locked. */
29
+ export declare function formatSettings(r: Resolved): string;