nikos 0.1.0 → 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.
- package/README.md +45 -9
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +57 -1
- package/dist/commands/approve.d.ts +16 -0
- package/dist/commands/approve.js +120 -0
- package/dist/commands/ci.d.ts +18 -0
- package/dist/commands/ci.js +123 -0
- package/dist/commands/clear.d.ts +14 -0
- package/dist/commands/decide.d.ts +11 -0
- package/dist/commands/decide.js +3 -3
- package/dist/commands/decisions.d.ts +14 -0
- package/dist/commands/decisions.js +2 -2
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/export.d.ts +48 -0
- package/dist/commands/export.js +164 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +16 -13
- package/dist/commands/join.d.ts +17 -0
- package/dist/commands/join.js +91 -0
- package/dist/commands/map.d.ts +65 -0
- package/dist/commands/map.js +202 -25
- package/dist/commands/migrate.d.ts +10 -0
- package/dist/commands/migrate.js +105 -0
- package/dist/commands/next.d.ts +19 -0
- package/dist/commands/next.js +29 -5
- package/dist/commands/plan.d.ts +8 -0
- package/dist/commands/plan.js +9 -8
- package/dist/commands/release.d.ts +66 -0
- package/dist/commands/release.js +119 -17
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +26 -4
- package/dist/commands/start.d.ts +28 -0
- package/dist/commands/start.js +138 -0
- package/dist/commands/state.d.ts +27 -0
- package/dist/commands/state.js +40 -22
- package/dist/commands/step.d.ts +17 -0
- package/dist/commands/step.js +38 -2
- package/dist/commands/story.d.ts +21 -0
- package/dist/commands/story.js +7 -10
- package/dist/commands/suggest.d.ts +16 -0
- package/dist/commands/suggest.js +140 -0
- package/dist/commands/tracker.d.ts +60 -0
- package/dist/commands/tracker.js +185 -0
- package/dist/commands/validate.d.ts +15 -0
- package/dist/commands/validate.js +149 -14
- package/dist/commands/verify.d.ts +20 -0
- package/dist/commands/video.d.ts +33 -0
- package/dist/commands/video.js +4 -3
- package/dist/index.d.ts +20 -0
- package/dist/index.js +24 -0
- package/dist/lib/approvals.d.ts +29 -0
- package/dist/lib/approvals.js +28 -0
- package/dist/lib/bets.d.ts +21 -0
- package/dist/lib/bets.js +9 -2
- package/dist/lib/frontmatter.d.ts +5 -0
- package/dist/lib/project.d.ts +25 -0
- package/dist/lib/project.js +25 -7
- package/dist/lib/settings.d.ts +29 -0
- package/dist/lib/settings.js +110 -0
- package/dist/lib/state-files.d.ts +44 -0
- package/dist/lib/state-files.js +102 -0
- package/dist/lib/store.d.ts +44 -0
- package/dist/lib/store.js +97 -0
- package/dist/lint/constitution.d.ts +5 -0
- package/dist/lint/gherkin.d.ts +12 -0
- package/dist/lint/gherkin.js +2 -2
- package/dist/lint/portable.d.ts +10 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +239 -0
- package/dist/planning/bets.d.ts +31 -0
- package/dist/planning/cli.d.ts +8 -0
- package/dist/planning/cli.js +17 -1
- package/dist/planning/delivery.d.ts +12 -0
- package/dist/planning/delivery.js +8 -5
- package/dist/planning/krs.d.ts +28 -0
- package/dist/planning/model.d.ts +55 -0
- package/dist/planning/model.js +10 -10
- package/dist/planning/rollout.d.ts +72 -0
- package/dist/planning/rollout.js +206 -0
- package/dist/planning/server.d.ts +113 -0
- package/dist/planning/server.js +9 -6
- package/dist/planning/ui.d.ts +7 -0
- package/dist/planning/ui.js +26 -1
- package/dist/planning/upstream.d.ts +73 -0
- package/dist/planning/upstream.js +78 -20
- package/dist/verify/detect.d.ts +3 -0
- package/docs/format.md +112 -2
- package/docs/gherkin.md +3 -0
- package/docs/install.md +39 -0
- package/docs/library.md +53 -0
- package/docs/planning.md +51 -5
- package/docs/settings.md +112 -0
- package/package.json +12 -2
- package/schemas/approvals.schema.json +27 -0
- package/schemas/planning/kr.schema.json +1 -1
- package/schemas/settings.schema.json +40 -0
- package/schemas/state.schema.json +6 -2
- package/schemas/storymap.schema.json +13 -0
- package/skills/nikos-mode/SKILL.md +22 -1
- package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
- package/skills/nikos-mode/playbooks/company.md +16 -0
- package/skills/nikos-mode/playbooks/index.md +4 -0
- package/skills/nikos-mode/playbooks/map-existing.md +14 -0
- package/skills/nikos-mode/playbooks/start.md +12 -0
- package/skills/nikos-mode/playbooks/tracker.md +14 -0
- package/skills/nikos-mode/trackers/github.md +18 -0
- package/skills/nikos-mode/trackers/gitlab.md +17 -0
- package/skills/nikos-mode/trackers/jira.md +19 -0
- package/skills/nikos-mode/trackers/linear.md +17 -0
- 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;
|
package/dist/commands/release.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
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,11 +37,23 @@ 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
|
-
|
|
40
|
+
store().write(file, setFeatureTag(text, "release", String(moved.get(id)), id));
|
|
37
41
|
}
|
|
38
42
|
}
|
|
43
|
+
/** Number the releases 1, 2, 3… in list order, retagging the stories of any that changed. */
|
|
44
|
+
function renumber(o) {
|
|
45
|
+
const moved = new Map();
|
|
46
|
+
o.releases.items.forEach((item, i) => {
|
|
47
|
+
if (numberOf(item) === i + 1)
|
|
48
|
+
return;
|
|
49
|
+
item.set("number", i + 1);
|
|
50
|
+
for (const id of storiesOf(item))
|
|
51
|
+
moved.set(id, i + 1);
|
|
52
|
+
});
|
|
53
|
+
retag(o.intentDir, moved);
|
|
54
|
+
}
|
|
39
55
|
function save(o) {
|
|
40
|
-
|
|
56
|
+
store().write(o.mapPath, o.map.toString(yamlOpts));
|
|
41
57
|
return validate(o.intentDir);
|
|
42
58
|
}
|
|
43
59
|
const cleanTitle = (title) => {
|
|
@@ -46,12 +62,19 @@ const cleanTitle = (title) => {
|
|
|
46
62
|
throw new Error("A release needs a title");
|
|
47
63
|
return t;
|
|
48
64
|
};
|
|
65
|
+
/** Refuse a title another release already has (ignoring case), so two releases can't share a name. */
|
|
66
|
+
function uniqueTitle(o, title, except) {
|
|
67
|
+
const taken = o.releases.items.find((r) => r !== except && String(r.get("title")).trim().toLowerCase() === title.toLowerCase());
|
|
68
|
+
if (taken)
|
|
69
|
+
throw new Error(`Release ${numberOf(taken)} is already called '${String(taken.get("title"))}'`);
|
|
70
|
+
return title;
|
|
71
|
+
}
|
|
49
72
|
/** Add the next numbered release, with no stories yet. */
|
|
50
73
|
export function addRelease(input, dir) {
|
|
51
74
|
const o = open(dir);
|
|
52
75
|
const number = Math.max(0, ...o.releases.items.map(numberOf)) + 1;
|
|
53
76
|
const goal = input.goal?.trim();
|
|
54
|
-
o.releases.add(o.map.createNode({ number, title: cleanTitle(input.title), ...(goal ? { goal } : {}), stories: [] }));
|
|
77
|
+
o.releases.add(o.map.createNode({ number, title: uniqueTitle(o, cleanTitle(input.title)), ...(goal ? { goal } : {}), stories: [] }));
|
|
55
78
|
const added = o.releases.items[o.releases.items.length - 1];
|
|
56
79
|
added.get("stories", true).flow = true;
|
|
57
80
|
return { number, validation: save(o) };
|
|
@@ -63,7 +86,7 @@ export function renameRelease(input, dir) {
|
|
|
63
86
|
const o = open(dir);
|
|
64
87
|
const r = find(o, input.number);
|
|
65
88
|
if (input.title !== undefined)
|
|
66
|
-
r.set("title", cleanTitle(input.title));
|
|
89
|
+
r.set("title", uniqueTitle(o, cleanTitle(input.title), r));
|
|
67
90
|
if (input.goal !== undefined) {
|
|
68
91
|
const goal = input.goal.trim();
|
|
69
92
|
if (!goal)
|
|
@@ -98,6 +121,22 @@ export function moveStory(input, dir) {
|
|
|
98
121
|
retag(o.intentDir, new Map([[input.id, input.to]]));
|
|
99
122
|
return { id: input.id, from: fromNumber, to: input.to, validation: save(o) };
|
|
100
123
|
}
|
|
124
|
+
/** Remove an empty release and renumber the ones after it, retagging their stories. */
|
|
125
|
+
export function removeRelease(input, dir) {
|
|
126
|
+
const o = open(dir);
|
|
127
|
+
const r = find(o, input.number);
|
|
128
|
+
const stories = storiesOf(r);
|
|
129
|
+
if (stories.length)
|
|
130
|
+
throw new Error(`Release ${input.number} still has ${stories.join(", ")}. Move its stories out first with nikos release move`);
|
|
131
|
+
const bets = r.get("bets", true);
|
|
132
|
+
if (isSeq(bets) && bets.items.length)
|
|
133
|
+
throw new Error(`Release ${input.number} delivers bets (${bets.items.map((b) => String(b?.value ?? b)).join(", ")}). Move them to another release first`);
|
|
134
|
+
const title = String(r.get("title"));
|
|
135
|
+
const items = o.releases.items;
|
|
136
|
+
items.splice(items.indexOf(r), 1);
|
|
137
|
+
renumber(o);
|
|
138
|
+
return { number: input.number, title, validation: save(o) };
|
|
139
|
+
}
|
|
101
140
|
/** Move a release to a new position and renumber them all from 1, retagging every story that moved. */
|
|
102
141
|
export function reorderRelease(input, dir) {
|
|
103
142
|
const o = open(dir);
|
|
@@ -108,22 +147,73 @@ export function reorderRelease(input, dir) {
|
|
|
108
147
|
const items = o.releases.items;
|
|
109
148
|
items.splice(items.indexOf(r), 1);
|
|
110
149
|
items.splice(input.to - 1, 0, r);
|
|
111
|
-
|
|
112
|
-
items.forEach((item, i) => {
|
|
113
|
-
if (numberOf(item) === i + 1)
|
|
114
|
-
return;
|
|
115
|
-
item.set("number", i + 1);
|
|
116
|
-
for (const id of storiesOf(item))
|
|
117
|
-
moved.set(id, i + 1);
|
|
118
|
-
});
|
|
119
|
-
retag(o.intentDir, moved);
|
|
150
|
+
renumber(o);
|
|
120
151
|
return { order: items.map((i) => ({ number: numberOf(i), title: String(i.get("title")) })), validation: save(o) };
|
|
121
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
|
+
}
|
|
122
210
|
const USAGE = `Usage:
|
|
123
211
|
nikos release add "<title>" [--goal "<goal>"]
|
|
124
212
|
nikos release rename <n> ["<title>"] [--goal "<goal>"] (--goal "" removes it)
|
|
125
213
|
nikos release move <story-id> <n>
|
|
126
|
-
nikos release reorder <n> <position
|
|
214
|
+
nikos release reorder <n> <position>
|
|
215
|
+
nikos release remove <n> (only an empty release)
|
|
216
|
+
nikos release check <n> [--run] (--run reruns each done story's scenarios)`;
|
|
127
217
|
function report(v) {
|
|
128
218
|
if (v.errors.length)
|
|
129
219
|
console.log(`\n! intent/ now has ${v.errors.length} ${v.errors.length === 1 ? "problem" : "problems"}; run nikos validate to see them.`);
|
|
@@ -157,6 +247,18 @@ export function runRelease(args) {
|
|
|
157
247
|
console.log(`Releases are now:\n${r.order.map((x) => ` ${x.number}. ${x.title}`).join("\n")}`);
|
|
158
248
|
report(r.validation);
|
|
159
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
|
+
}
|
|
257
|
+
else if (sub === "remove" && positional[0]) {
|
|
258
|
+
const r = removeRelease({ number: Number(positional[0]) }, dir);
|
|
259
|
+
console.log(`Removed release ${r.number}: ${r.title}`);
|
|
260
|
+
report(r.validation);
|
|
261
|
+
}
|
|
160
262
|
else {
|
|
161
263
|
console.error(USAGE);
|
|
162
264
|
return 1;
|
package/dist/commands/show.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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) => (
|
|
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
|
|
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;
|
package/dist/commands/state.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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;
|