nikos 0.0.6 → 0.1.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 +5 -4
- package/dist/cli.js +21 -5
- package/dist/commands/map.js +272 -0
- package/dist/commands/release.js +170 -0
- package/dist/commands/show.js +7 -7
- package/dist/commands/step.js +59 -0
- package/dist/commands/story.js +9 -3
- package/dist/commands/validate.js +46 -31
- package/dist/commands/video.js +116 -0
- package/dist/lib/bets.js +25 -0
- package/dist/lib/frontmatter.js +8 -0
- package/dist/lib/project.js +37 -0
- package/dist/lint/gherkin.js +2 -2
- package/dist/planning/bets.js +73 -0
- package/dist/planning/cli.js +152 -0
- package/dist/planning/delivery.js +20 -0
- package/dist/planning/krs.js +73 -0
- package/dist/planning/model.js +164 -0
- package/dist/planning/server.js +336 -0
- package/dist/planning/ui.js +452 -0
- package/dist/planning/upstream.js +175 -0
- package/docs/format.md +50 -55
- package/docs/gherkin.md +2 -1
- package/docs/install.md +26 -5
- package/docs/planning.md +124 -0
- package/package.json +2 -1
- package/schemas/planning/bet.schema.json +42 -0
- package/schemas/planning/kr.schema.json +35 -0
- package/schemas/planning/plan.schema.json +28 -0
- package/schemas/storymap.schema.json +94 -11
- package/skills/nikos-mode/playbooks/idea-to-blueprint.md +1 -1
- package/skills/nikos-mode/playbooks/index.md +2 -1
- package/skills/nikos-mode/playbooks/planning.md +11 -0
- package/skills/nikos-mode/playbooks/video.md +14 -0
- package/skills/nikos-mode/principles/index.md +0 -2
- package/dist/commands/bet.js +0 -97
- package/docs/intentdocs-mapping.md +0 -117
- package/schemas/bet.schema.json +0 -23
- package/skills/nikos-mode/playbooks/bet.md +0 -14
package/README.md
CHANGED
|
@@ -62,13 +62,12 @@ it"); the playbooks run these steps and stop at each point that needs you.
|
|
|
62
62
|
```text
|
|
63
63
|
intent/
|
|
64
64
|
├── constitution.md who it's for, the problem, what proves it works
|
|
65
|
-
├── storymap.yaml activities, releases, and the order to build stories in
|
|
65
|
+
├── storymap.yaml activities and steps, releases, and the order to build stories in
|
|
66
66
|
├── state.yaml each story's status: todo, in_progress, needs_rework, done
|
|
67
67
|
├── decisions.md why things are the way they are, append-only
|
|
68
68
|
├── features/ one .feature file per story; its scenarios are the criteria
|
|
69
69
|
│ └── <activity>/<story>.feature
|
|
70
|
-
|
|
71
|
-
└── bets/ time-boxed bets and the stories that test them
|
|
70
|
+
└── plans/ how each story will be built, versioned: <story>.v<N>.md
|
|
72
71
|
```
|
|
73
72
|
|
|
74
73
|
Every file is plain text you can read, review and diff. The full format:
|
|
@@ -82,6 +81,8 @@ Every file is plain text you can read, review and diff. The full format:
|
|
|
82
81
|
| `nikos init` | Create `intent/` in this repo |
|
|
83
82
|
| `nikos validate` | Check `intent/` is well formed |
|
|
84
83
|
| `nikos story add "<title>"` | Add a story to the map, state and a starter feature |
|
|
84
|
+
| `nikos step add "<title>"` | Add a step under an activity |
|
|
85
|
+
| `nikos release add\|rename\|move\|reorder` | Define releases and move stories between them, keeping feature tags in step |
|
|
85
86
|
| `nikos clear <id>` | Check a story is ready to build |
|
|
86
87
|
| `nikos plan new <id>` | Start the next version of a story's plan |
|
|
87
88
|
| `nikos verify setup` | Add a Cucumber runner for this repo's stack |
|
|
@@ -91,7 +92,7 @@ Every file is plain text you can read, review and diff. The full format:
|
|
|
91
92
|
| `nikos show <id>` | Summarise a story: feature, status, plan, bets, decisions |
|
|
92
93
|
| `nikos decide "<title>"` | Append a decision to `decisions.md` |
|
|
93
94
|
| `nikos decisions "<q>"` | Quote the decisions about a topic or story |
|
|
94
|
-
| `nikos
|
|
95
|
+
| `nikos map` | Write a read-only story map page |
|
|
95
96
|
|
|
96
97
|
## Links
|
|
97
98
|
|
package/dist/cli.js
CHANGED
|
@@ -7,10 +7,14 @@ import { runVerify } from "./commands/verify.js";
|
|
|
7
7
|
import { runNext } from "./commands/next.js";
|
|
8
8
|
import { runState } from "./commands/state.js";
|
|
9
9
|
import { runDecide } from "./commands/decide.js";
|
|
10
|
-
import { runBet } from "./commands/bet.js";
|
|
11
10
|
import { runShow } from "./commands/show.js";
|
|
12
11
|
import { runDecisions } from "./commands/decisions.js";
|
|
13
12
|
import { runStory } from "./commands/story.js";
|
|
13
|
+
import { runStep } from "./commands/step.js";
|
|
14
|
+
import { runRelease } from "./commands/release.js";
|
|
15
|
+
import { runVideo } from "./commands/video.js";
|
|
16
|
+
import { runMap } from "./commands/map.js";
|
|
17
|
+
import { runPlanning } from "./planning/cli.js";
|
|
14
18
|
const commands = {
|
|
15
19
|
init: runInit,
|
|
16
20
|
validate: runValidate,
|
|
@@ -20,10 +24,14 @@ const commands = {
|
|
|
20
24
|
next: runNext,
|
|
21
25
|
state: runState,
|
|
22
26
|
decide: runDecide,
|
|
23
|
-
bet: runBet,
|
|
24
27
|
show: runShow,
|
|
25
28
|
decisions: runDecisions,
|
|
26
29
|
story: runStory,
|
|
30
|
+
step: runStep,
|
|
31
|
+
release: runRelease,
|
|
32
|
+
video: runVideo,
|
|
33
|
+
map: runMap,
|
|
34
|
+
planning: runPlanning,
|
|
27
35
|
};
|
|
28
36
|
const help = `nikos — spec-driven development for AI coding agents
|
|
29
37
|
|
|
@@ -33,7 +41,12 @@ Commands:
|
|
|
33
41
|
init Create intent/ in this repo (--name, --key)
|
|
34
42
|
validate [dir] Check the intent/ folder is well formed
|
|
35
43
|
story add "<title>" Add a story to the map, state and a starter feature
|
|
36
|
-
(--activity, --release, --priority)
|
|
44
|
+
(--activity, --step, --release, --priority)
|
|
45
|
+
step add "<title>" Add a step under an activity (--activity, --id)
|
|
46
|
+
release add "<title>" Add the next release (--goal)
|
|
47
|
+
release rename <n> Change a release's title or goal (--goal)
|
|
48
|
+
release move <id> <n> Move a story to another release, retagging its feature
|
|
49
|
+
release reorder <n> <pos> Move a release and renumber them all
|
|
37
50
|
plan new <id> Start the next version of a story's plan
|
|
38
51
|
clear <id> Check a story is Lean and Executable before building
|
|
39
52
|
verify setup Set up a Cucumber runner for this repo's stack
|
|
@@ -44,8 +57,11 @@ Commands:
|
|
|
44
57
|
state set <id> <status> Move a story; done only if its scenarios pass
|
|
45
58
|
decide "<title>" Append a decision to decisions.md
|
|
46
59
|
(--why, --story, --category, --supersedes)
|
|
47
|
-
|
|
48
|
-
(--
|
|
60
|
+
video Draft a video brief and storyboard from the done stories
|
|
61
|
+
(--out, default marketing/storymap-video)
|
|
62
|
+
map Write a read-only story map page (--out, default storymap.html)
|
|
63
|
+
planning <cmd> Bets, key results and the planning dashboard (nikos-planning pack;
|
|
64
|
+
run \`nikos planning help\`)
|
|
49
65
|
|
|
50
66
|
Docs: https://nikos.tools`;
|
|
51
67
|
async function main(argv) {
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// The story map: activities as columns, releases as numbered bands, stories as cards tinted
|
|
2
|
+
// by state.yaml. Read-only and script-free: the page has nothing to edit and runs no code, so
|
|
3
|
+
// it can be shared as a file. Selecting a story opens its panel with :target, in CSS alone.
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, relative, resolve } from "node:path";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { featureStoryIds, findIntentDir, read, walk } from "../lib/project.js";
|
|
8
|
+
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
9
|
+
const yamlAt = (path) => (existsSync(path) ? parse(read(path)) : undefined);
|
|
10
|
+
const PRIORITY = /@(must|should|could|wont)\b/;
|
|
11
|
+
/** One story's details from its feature file: title, tags, narrative and criteria. */
|
|
12
|
+
function featureStory(text, id) {
|
|
13
|
+
const [head, body = ""] = text.split(/^\s*Feature:/m);
|
|
14
|
+
const lines = body.split("\n");
|
|
15
|
+
const narrative = [];
|
|
16
|
+
for (const line of lines.slice(1)) {
|
|
17
|
+
const t = line.trim();
|
|
18
|
+
if (t.startsWith("@") || /^(Scenario|Background|Rule|Scenario Outline):/.test(t))
|
|
19
|
+
break;
|
|
20
|
+
if (t)
|
|
21
|
+
narrative.push(t);
|
|
22
|
+
}
|
|
23
|
+
const criteria = [];
|
|
24
|
+
let tags = "";
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const t = line.trim();
|
|
27
|
+
if (t.startsWith("@"))
|
|
28
|
+
tags = t;
|
|
29
|
+
const m = /^Scenario(?: Outline)?:\s*(.+)$/.exec(t);
|
|
30
|
+
if (!m)
|
|
31
|
+
continue;
|
|
32
|
+
const ac = /@(AC\d+)\b/.exec(tags)?.[1] ?? "";
|
|
33
|
+
criteria.push({ id: ac, title: m[1].replace(/^AC\d+\s*[—–-]\s*/, ""), priority: PRIORITY.exec(tags)?.[1] ?? "" });
|
|
34
|
+
tags = "";
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
title: lines[0]?.trim() || id,
|
|
39
|
+
activity: /@activity-([a-z0-9-]+)/.exec(head)?.[1] ?? "",
|
|
40
|
+
step: /@step-([a-z0-9-]+)/.exec(head)?.[1] ?? "",
|
|
41
|
+
priority: PRIORITY.exec(head)?.[1] ?? "",
|
|
42
|
+
effort: /@effort-([A-Za-z0-9]+)/.exec(head)?.[1] ?? "",
|
|
43
|
+
narrative: narrative.map((l, i) => (i ? l.replace(/^So that\b/, "so that") : l)).join(", "),
|
|
44
|
+
criteria,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The story map as data: activities, releases with their bets, and every story's status. */
|
|
48
|
+
export function storyMapOf(dir) {
|
|
49
|
+
const intent = findIntentDir(dir);
|
|
50
|
+
const map = yamlAt(join(intent, "storymap.yaml")) ?? {};
|
|
51
|
+
const states = yamlAt(join(intent, "state.yaml"))?.stories ?? {};
|
|
52
|
+
const stateOf = (id) => ({ status: states[id]?.status ?? "todo", blocked: (states[id]?.blockers ?? []).length > 0 });
|
|
53
|
+
const features = new Map();
|
|
54
|
+
for (const file of walk(join(intent, "features"), ".feature")) {
|
|
55
|
+
const text = read(file);
|
|
56
|
+
const id = featureStoryIds(text)[0];
|
|
57
|
+
if (!id)
|
|
58
|
+
continue;
|
|
59
|
+
features.set(id, { ...featureStory(text, id), ...stateOf(id) });
|
|
60
|
+
}
|
|
61
|
+
const story = (id) => features.get(id) ?? { id, title: id, activity: "", step: "", priority: "", effort: "", narrative: "", criteria: [], ...stateOf(id) };
|
|
62
|
+
const releases = (Array.isArray(map.releases) ? map.releases : []).map((r) => ({
|
|
63
|
+
number: Number(r.number),
|
|
64
|
+
title: String(r.title ?? `Release ${r.number}`),
|
|
65
|
+
goal: String(r.goal ?? ""),
|
|
66
|
+
bets: Array.isArray(r.bets) ? r.bets.map(String) : [],
|
|
67
|
+
stories: (Array.isArray(r.stories) ? r.stories : []).map((s) => story(String(s))),
|
|
68
|
+
}));
|
|
69
|
+
const placed = new Set(releases.flatMap((r) => r.stories.map((s) => s.id)));
|
|
70
|
+
return {
|
|
71
|
+
project: String(map.project ?? ""),
|
|
72
|
+
activities: (Array.isArray(map.activities) ? map.activities : []).map((a) => ({
|
|
73
|
+
id: String(a.id),
|
|
74
|
+
title: String(a.title ?? a.id),
|
|
75
|
+
steps: (Array.isArray(a.steps) ? a.steps : []).map((st) => ({ id: String(st.id), title: String(st.title ?? st.id) })),
|
|
76
|
+
})),
|
|
77
|
+
releases,
|
|
78
|
+
unreleased: [...features.values()].filter((s) => !placed.has(s.id)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Each status's tint class and the word shown on its card. Blocked wins over the status. */
|
|
82
|
+
const STATUS = {
|
|
83
|
+
todo: { cls: "todo", word: "to do" },
|
|
84
|
+
in_progress: { cls: "doing", word: "in progress" },
|
|
85
|
+
needs_rework: { cls: "rework", word: "rework" },
|
|
86
|
+
done: { cls: "done", word: "done" },
|
|
87
|
+
};
|
|
88
|
+
const statusOf = (s) => (s.blocked ? { cls: "blocked", word: "blocked" } : STATUS[s.status] ?? STATUS.todo);
|
|
89
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
90
|
+
/** The whole story map as one standalone HTML page. */
|
|
91
|
+
export function renderMap(dir) {
|
|
92
|
+
const m = storyMapOf(dir);
|
|
93
|
+
// One column per step; an activity with no steps (or the "Other" bucket) gets one column.
|
|
94
|
+
const known = new Set(m.activities.map((a) => a.id));
|
|
95
|
+
const everything = [...m.releases.flatMap((r) => r.stories), ...m.unreleased];
|
|
96
|
+
const groups = [
|
|
97
|
+
...m.activities,
|
|
98
|
+
...(everything.some((s) => !known.has(s.activity)) ? [{ id: "", title: "Other", steps: [] }] : []),
|
|
99
|
+
].map((a) => ({ ...a, columns: a.steps.length ? a.steps.map((st) => ({ key: `${a.id}/${st.id}`, label: `${a.title} · ${st.title}` })) : [{ key: `${a.id}/`, label: a.title }] }));
|
|
100
|
+
const columns = groups.flatMap((g) => g.columns);
|
|
101
|
+
const columnOf = (s) => {
|
|
102
|
+
const g = groups.find((x) => x.id === (known.has(s.activity) ? s.activity : ""));
|
|
103
|
+
return g.columns.find((c) => c.key === `${g.id}/${s.step}`)?.key ?? g.columns[0].key;
|
|
104
|
+
};
|
|
105
|
+
const hasSteps = m.activities.some((a) => a.steps.length);
|
|
106
|
+
const card = (s) => {
|
|
107
|
+
const st = statusOf(s);
|
|
108
|
+
const musts = s.criteria.filter((c) => c.priority === "must").length;
|
|
109
|
+
const foot = s.criteria.length
|
|
110
|
+
? `<span>${s.criteria.length} ${s.criteria.length === 1 ? "criterion" : "criteria"}${musts ? ` · ${musts} must` : ""}</span>`
|
|
111
|
+
: `<span class="warn">No criteria</span>`;
|
|
112
|
+
return `<a class="card ${st.cls}" data-story="${esc(s.id)}" href="#story-${esc(s.id)}"><span class="crow"><span class="sid">${esc(s.id)}</span><span class="sword">${esc(st.word)}</span></span><span class="stitle">${esc(s.title)}</span><span class="cfoot">${foot}</span></a>`;
|
|
113
|
+
};
|
|
114
|
+
const tally = (stories) => {
|
|
115
|
+
const n = (f) => stories.filter(f).length;
|
|
116
|
+
const parts = [`${n((s) => s.status === "done")} done`, `${n((s) => s.status === "in_progress")} in progress`, `${n((s) => s.status === "todo")} to do`];
|
|
117
|
+
const blocked = n((s) => s.blocked);
|
|
118
|
+
return parts.join(" · ") + (blocked ? ` · <span class="warn">${blocked} blocked</span>` : "");
|
|
119
|
+
};
|
|
120
|
+
const band = (id, code, heading, goal, bets, stories) => {
|
|
121
|
+
const shipped = stories.length > 0 && stories.every((s) => s.status === "done");
|
|
122
|
+
const chips = bets.map((b) => `<span class="bet">${esc(b)}</span>`).join("");
|
|
123
|
+
const cells = columns
|
|
124
|
+
.map((c) => `<div class="cell" data-activity="${esc(c.label)}">${stories.filter((s) => columnOf(s) === c.key).map(card).join("")}</div>`)
|
|
125
|
+
.join("");
|
|
126
|
+
return `<section class="band" id="${esc(id)}"><div class="divider"><span class="rnum">${esc(code)}</span><h2>${esc(heading)}</h2>${shipped ? `<span class="stamp">Shipped</span>` : ""}<span class="tally">${stories.length ? tally(stories) : "no stories"}</span><span class="rule"></span></div>${goal ? `<p class="goal">${esc(goal)}</p>` : ""}${chips ? `<div class="bets"><span class="eyebrow">Delivers</span>${chips}</div>` : ""}${stories.length ? `<div class="lane">${cells}</div>` : `<p class="empty">No stories yet.</p>`}</section>`;
|
|
127
|
+
};
|
|
128
|
+
const bands = m.releases.map((r) => band(`release-${r.number}`, pad(r.number), `Release ${r.number} · ${r.title}`, r.goal, r.bets, r.stories)).join("");
|
|
129
|
+
const unreleased = m.unreleased.length ? band("unreleased", "--", "Not in a release", "", [], m.unreleased) : "";
|
|
130
|
+
const spine = `<div class="spine"><div class="activities">${groups
|
|
131
|
+
.map((g) => `<div class="activity" style="--span:${g.columns.length}"><span class="eyebrow">Activity</span><strong>${esc(g.title)}</strong></div>`)
|
|
132
|
+
.join("")}</div>${hasSteps ? `<div class="steps">${groups.map((g) => (g.steps.length ? g.steps.map((st) => `<div class="step">${esc(st.title)}</div>`).join("") : `<div class="step none"></div>`)).join("")}</div>` : ""}</div>`;
|
|
133
|
+
const jumps = [
|
|
134
|
+
...m.releases.map((r) => `<a class="jump" href="#release-${r.number}"><span class="rcode">R${r.number}</span>${esc(r.title)}<span class="count">${r.stories.length}</span></a>`),
|
|
135
|
+
...(m.unreleased.length ? [`<a class="jump" href="#unreleased">Not in a release<span class="count">${m.unreleased.length}</span></a>`] : []),
|
|
136
|
+
].join("");
|
|
137
|
+
const panel = (s) => {
|
|
138
|
+
const st = statusOf(s);
|
|
139
|
+
const facts = [s.id, s.priority, s.effort && `effort ${s.effort}`].filter(Boolean).map((f) => esc(f)).join(" · ");
|
|
140
|
+
const criteria = s.criteria.length
|
|
141
|
+
? `<ol class="criteria">${s.criteria.map((c) => `<li><span class="sid">${esc(c.id)}</span>${esc(c.title)}${c.priority ? ` <span class="prio">${esc(c.priority)}</span>` : ""}</li>`).join("")}</ol>`
|
|
142
|
+
: `<p class="warn">No criteria yet.</p>`;
|
|
143
|
+
return `<div class="sheet-wrap" id="story-${esc(s.id)}"><a class="scrim" href="#" aria-label="Close"></a><aside class="sheet" aria-label="${esc(s.id)}"><div class="sheet-head"><span class="sid">${esc(s.id)}</span><a class="close" href="#" aria-label="Close">×</a></div><div class="status-card ${st.cls}"><span class="sword">${esc(st.word)}</span><div class="sc-title">${esc(s.title)}</div><div class="facts">${facts}</div>${s.narrative ? `<p class="narrative">${esc(s.narrative)}</p>` : ""}</div><div class="eyebrow">Acceptance criteria</div>${criteria}</aside></div>`;
|
|
144
|
+
};
|
|
145
|
+
const title = m.project ? `${m.project} · Story map` : "Story map";
|
|
146
|
+
return `<!doctype html>
|
|
147
|
+
<html lang="en">
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8">
|
|
150
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
151
|
+
<title>${esc(title)}</title>
|
|
152
|
+
<style>${CSS}</style>
|
|
153
|
+
</head>
|
|
154
|
+
<body>
|
|
155
|
+
<header class="top"><h1>${esc(title)}</h1><nav class="jumps" aria-label="Releases">${jumps}</nav></header>
|
|
156
|
+
<main>
|
|
157
|
+
<div class="board" style="--cols:${Math.max(1, columns.length)}">
|
|
158
|
+
${spine}
|
|
159
|
+
${bands}
|
|
160
|
+
${unreleased}
|
|
161
|
+
</div>
|
|
162
|
+
</main>
|
|
163
|
+
${everything.map(panel).join("\n")}
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
const CSS = `
|
|
169
|
+
:root { --paper: #FAFAF7; --surface: #FFFFFF; --elevated: #F2F0E9; --line: #E8E6DF; --line-2: #D6D3C8; --ink: #1A1A18; --ink-2: #57534E; --muted: #6E695F; --brand: #1B4FD8; --brand-subtle: rgba(27,79,216,.07); --stamp: #DC2626; --warn: #B45309; --col: 240px; --gap: 16px; }
|
|
170
|
+
* { box-sizing: border-box; }
|
|
171
|
+
body { margin: 0; height: 100vh; display: flex; flex-direction: column; background: var(--paper); color: var(--ink); font: 13px/1.45 Inter, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
172
|
+
.sid, .rnum, .rcode, .count, .tally, .cfoot, .facts, .stamp { font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; }
|
|
173
|
+
.eyebrow { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }
|
|
174
|
+
.warn { color: var(--warn); font-weight: 600; }
|
|
175
|
+
.top { padding: 16px 24px 12px; display: flex; flex-wrap: wrap; gap: 10px 24px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: var(--paper); }
|
|
176
|
+
h1 { font-size: 15px; margin: 0; }
|
|
177
|
+
.jumps { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
178
|
+
.jump { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 1px solid var(--line); border-radius: 99px; background: var(--surface); color: var(--ink); text-decoration: none; font-size: 12px; }
|
|
179
|
+
.jump:hover { border-color: var(--line-2); }
|
|
180
|
+
.rcode, .count { font-size: 11px; color: var(--muted); }
|
|
181
|
+
main { flex: 1; min-height: 0; overflow: auto; background-color: var(--paper); background-image: linear-gradient(rgba(26,26,24,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(26,26,24,.06) 1px, transparent 1px); background-size: 24px 24px; background-attachment: local; }
|
|
182
|
+
.board { width: max-content; min-width: 100%; padding: 0 24px 48px; }
|
|
183
|
+
.spine { position: sticky; top: 0; z-index: 3; padding: 24px 0 4px; background: var(--paper); }
|
|
184
|
+
.activities, .steps { display: flex; gap: var(--gap); }
|
|
185
|
+
.steps { margin-top: 8px; }
|
|
186
|
+
.step { width: var(--col); min-height: 32px; border-radius: 4px; background: var(--brand-subtle); padding: 7px 10px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }
|
|
187
|
+
.step.none { background: none; }
|
|
188
|
+
.activity { width: calc(var(--span, 1) * var(--col) + (var(--span, 1) - 1) * var(--gap)); display: grid; gap: 2px; background: var(--surface); border: 1px solid var(--line); border-top: 2px solid var(--brand); border-radius: 4px; padding: 10px 14px; box-shadow: 0 1px 2px rgba(60,50,30,.06); }
|
|
189
|
+
.activity strong { font-size: 13px; }
|
|
190
|
+
.band { scroll-margin-top: 90px; }
|
|
191
|
+
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0 10px; }
|
|
192
|
+
.rnum { color: var(--brand); font-size: 13px; font-weight: 600; }
|
|
193
|
+
.divider h2 { margin: 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); white-space: nowrap; }
|
|
194
|
+
.tally { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
|
195
|
+
.rule { flex: 1; border-top: 1px solid var(--line); min-width: 24px; }
|
|
196
|
+
.stamp { display: inline-block; transform: rotate(-3deg); color: var(--stamp); border: 2px solid var(--stamp); box-shadow: inset 0 0 0 1px rgba(220,38,38,.45); padding: .15rem .5rem; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; opacity: .85; mix-blend-mode: multiply; }
|
|
197
|
+
.goal { margin: -4px 0 10px; color: var(--ink-2); max-width: 720px; }
|
|
198
|
+
.bets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: -4px 0 10px; }
|
|
199
|
+
.bet { font-size: 12px; padding: 2px 9px; border-radius: 99px; background: var(--brand-subtle); color: var(--brand); }
|
|
200
|
+
.lane { display: flex; gap: var(--gap); }
|
|
201
|
+
.cell { width: var(--col); min-height: 48px; display: flex; flex-direction: column; gap: 4px; }
|
|
202
|
+
.empty { color: var(--muted); margin: 0 0 8px; }
|
|
203
|
+
.card { --fill: #F4F3EE; --edge: #E3E1D9; --word: #7D786E; display: flex; flex-direction: column; min-height: 32px; border: 1px solid var(--edge); border-radius: 4px; background: var(--fill); color: var(--ink); text-decoration: none; box-shadow: 0 1px 2px rgba(60,50,30,.06); }
|
|
204
|
+
.card:hover { border-color: var(--line-2); }
|
|
205
|
+
.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; box-shadow: 0 0 0 5px rgba(27,79,216,.15); }
|
|
206
|
+
.crow { display: flex; align-items: baseline; gap: 6px; padding: 4px 8px 0; }
|
|
207
|
+
.card .sid { font-size: 11px; letter-spacing: .06em; color: var(--muted); }
|
|
208
|
+
.sword { margin-left: auto; font-size: 11px; font-weight: 500; color: var(--word); }
|
|
209
|
+
.sword::first-letter { text-transform: uppercase; }
|
|
210
|
+
.stitle { padding: 2px 8px; font-size: 13px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
211
|
+
.cfoot { display: flex; justify-content: space-between; padding: 0 8px 4px; font-size: 11px; letter-spacing: .04em; color: var(--muted); }
|
|
212
|
+
.doing { --fill: #EDF3FD; --edge: #CFDDF8; --word: #1E7A50; }
|
|
213
|
+
.card.doing { border-left: 4px solid #1E9E5C; }
|
|
214
|
+
.rework { --fill: #F1EEFB; --edge: #DCD5F3; --word: #C2680F; }
|
|
215
|
+
.card.rework { border-left: 4px solid #E8892B; }
|
|
216
|
+
.done { --fill: #F1EEFB; --edge: #DCD5F3; --word: #6D5BB8; }
|
|
217
|
+
.card.done .stitle { text-decoration: line-through; text-decoration-color: rgba(26,26,24,.35); }
|
|
218
|
+
.blocked { --fill: #FDECEC; --edge: #F3C2C2; --word: #B83232; }
|
|
219
|
+
.card.blocked { border-left: 4px solid #D64545; }
|
|
220
|
+
.sheet-wrap { display: none; }
|
|
221
|
+
.sheet-wrap:target { display: block; position: fixed; inset: 0; z-index: 10; }
|
|
222
|
+
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.4); }
|
|
223
|
+
.sheet { position: absolute; top: 0; right: 0; bottom: 0; width: 700px; max-width: 95vw; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--line); box-shadow: 0 25px 50px -12px rgba(0,0,0,.25); padding: 16px 20px 32px; animation: slide .28s cubic-bezier(.2,.8,.3,1); }
|
|
224
|
+
@keyframes slide { from { transform: translateX(40px); opacity: 0; } }
|
|
225
|
+
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
|
226
|
+
.close { font-size: 22px; line-height: 1; color: var(--muted); text-decoration: none; padding: 0 4px; }
|
|
227
|
+
.status-card { border: 1px solid var(--edge, var(--line)); background: var(--fill, var(--surface)); border-radius: 8px; padding: 14px; margin-bottom: 20px; }
|
|
228
|
+
.status-card .sword { display: block; margin: 0 0 4px; }
|
|
229
|
+
.sc-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
|
230
|
+
.facts { font-size: 11px; color: var(--muted); }
|
|
231
|
+
.narrative { margin: 10px 0 0; color: var(--ink-2); }
|
|
232
|
+
.criteria { margin: 8px 0 0; padding-left: 0; list-style: none; display: grid; gap: 6px; }
|
|
233
|
+
.criteria li { border: 1px solid var(--line); border-radius: 4px; padding: 8px 10px; }
|
|
234
|
+
.criteria .sid { font-size: 11px; color: var(--muted); margin-right: 8px; }
|
|
235
|
+
.prio { font-size: 11px; color: var(--muted); }
|
|
236
|
+
@media (prefers-reduced-motion: reduce) { .sheet { animation: none; } }
|
|
237
|
+
@media (max-width: 767px) {
|
|
238
|
+
body { height: auto; display: block; }
|
|
239
|
+
.top { padding: 12px 16px; }
|
|
240
|
+
main { background-image: none; }
|
|
241
|
+
.board { width: auto; padding: 0 16px 32px; }
|
|
242
|
+
.spine { display: none; }
|
|
243
|
+
.lane { display: block; }
|
|
244
|
+
.cell { width: auto; min-height: 0; }
|
|
245
|
+
.cell:empty { display: none; }
|
|
246
|
+
.cell::before { content: attr(data-activity); display: block; margin: 12px 0 6px; padding-bottom: 4px; border-bottom: 1px solid rgba(27,79,216,.3); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--brand); }
|
|
247
|
+
.band { scroll-margin-top: 12px; }
|
|
248
|
+
.divider { flex-wrap: wrap; gap: 4px 12px; }
|
|
249
|
+
.rule { display: none; }
|
|
250
|
+
}
|
|
251
|
+
`;
|
|
252
|
+
/** `nikos map`: write the story map as a standalone page. */
|
|
253
|
+
export function runMap(args) {
|
|
254
|
+
const flag = (name) => {
|
|
255
|
+
const i = args.indexOf(name);
|
|
256
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
257
|
+
};
|
|
258
|
+
const out = resolve(flag("--out") ?? "storymap.html");
|
|
259
|
+
const dir = resolve(flag("--dir") ?? process.cwd());
|
|
260
|
+
try {
|
|
261
|
+
if (!existsSync(join(findIntentDir(dir), "storymap.yaml")))
|
|
262
|
+
throw new Error(`No storymap.yaml in ${findIntentDir(dir)}: run nikos init first`);
|
|
263
|
+
writeFileSync(out, renderMap(dir));
|
|
264
|
+
const shown = relative(process.cwd(), out);
|
|
265
|
+
console.log(`Wrote ${shown && !shown.startsWith("..") ? shown : out}`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(`✗ ${err.message}`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isMap, isSeq, parseDocument } from "yaml";
|
|
4
|
+
import { featureStoryIds, findIntentDir, read, setFeatureTag, walk } from "../lib/project.js";
|
|
5
|
+
import { validate } from "./validate.js";
|
|
6
|
+
const yamlOpts = { lineWidth: 0, flowCollectionPadding: false };
|
|
7
|
+
function open(dir) {
|
|
8
|
+
const intentDir = findIntentDir(dir);
|
|
9
|
+
const mapPath = join(intentDir, "storymap.yaml");
|
|
10
|
+
// A Document keeps comments and the order of everything we don't touch.
|
|
11
|
+
const map = parseDocument(read(mapPath));
|
|
12
|
+
const releases = map.get("releases", true);
|
|
13
|
+
if (!isSeq(releases))
|
|
14
|
+
throw new Error("storymap.yaml has no releases list");
|
|
15
|
+
return { intentDir, mapPath, map, releases: releases };
|
|
16
|
+
}
|
|
17
|
+
const numberOf = (r) => Number(r.get("number"));
|
|
18
|
+
const storiesOf = (r) => {
|
|
19
|
+
const s = r.get("stories", true);
|
|
20
|
+
return isSeq(s) ? s.items.map((i) => String(i?.value ?? i)) : [];
|
|
21
|
+
};
|
|
22
|
+
function find(o, n) {
|
|
23
|
+
const r = o.releases.items.find((i) => isMap(i) && numberOf(i) === n);
|
|
24
|
+
if (!r)
|
|
25
|
+
throw new Error(`There is no release ${n}. Releases: ${o.releases.items.map(numberOf).join(", ") || "none"}`);
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
/** Rewrite the @release-<n> tag on each moved story's feature file. */
|
|
29
|
+
function retag(intentDir, moved) {
|
|
30
|
+
if (!moved.size)
|
|
31
|
+
return;
|
|
32
|
+
for (const file of walk(join(intentDir, "features"), ".feature")) {
|
|
33
|
+
const text = read(file);
|
|
34
|
+
const id = featureStoryIds(text)[0];
|
|
35
|
+
if (id && moved.has(id))
|
|
36
|
+
writeFileSync(file, setFeatureTag(text, "release", String(moved.get(id)), id));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function save(o) {
|
|
40
|
+
writeFileSync(o.mapPath, o.map.toString(yamlOpts));
|
|
41
|
+
return validate(o.intentDir);
|
|
42
|
+
}
|
|
43
|
+
const cleanTitle = (title) => {
|
|
44
|
+
const t = title?.trim();
|
|
45
|
+
if (!t)
|
|
46
|
+
throw new Error("A release needs a title");
|
|
47
|
+
return t;
|
|
48
|
+
};
|
|
49
|
+
/** Add the next numbered release, with no stories yet. */
|
|
50
|
+
export function addRelease(input, dir) {
|
|
51
|
+
const o = open(dir);
|
|
52
|
+
const number = Math.max(0, ...o.releases.items.map(numberOf)) + 1;
|
|
53
|
+
const goal = input.goal?.trim();
|
|
54
|
+
o.releases.add(o.map.createNode({ number, title: cleanTitle(input.title), ...(goal ? { goal } : {}), stories: [] }));
|
|
55
|
+
const added = o.releases.items[o.releases.items.length - 1];
|
|
56
|
+
added.get("stories", true).flow = true;
|
|
57
|
+
return { number, validation: save(o) };
|
|
58
|
+
}
|
|
59
|
+
/** Change a release's title and/or goal. An empty goal removes it. */
|
|
60
|
+
export function renameRelease(input, dir) {
|
|
61
|
+
if (input.title === undefined && input.goal === undefined)
|
|
62
|
+
throw new Error("Give a new title, a --goal, or both");
|
|
63
|
+
const o = open(dir);
|
|
64
|
+
const r = find(o, input.number);
|
|
65
|
+
if (input.title !== undefined)
|
|
66
|
+
r.set("title", cleanTitle(input.title));
|
|
67
|
+
if (input.goal !== undefined) {
|
|
68
|
+
const goal = input.goal.trim();
|
|
69
|
+
if (!goal)
|
|
70
|
+
r.delete("goal");
|
|
71
|
+
else if (r.has("goal"))
|
|
72
|
+
r.set("goal", goal);
|
|
73
|
+
else {
|
|
74
|
+
// Keep goal next to the title rather than after the stories.
|
|
75
|
+
const at = r.items.findIndex((p) => p.key?.value === "title") + 1;
|
|
76
|
+
r.items.splice(at, 0, o.map.createPair("goal", goal));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { number: input.number, validation: save(o) };
|
|
80
|
+
}
|
|
81
|
+
/** Move a story to another release (to the end of its build order), keeping its feature tag in step. */
|
|
82
|
+
export function moveStory(input, dir) {
|
|
83
|
+
const o = open(dir);
|
|
84
|
+
const target = find(o, input.to);
|
|
85
|
+
const from = o.releases.items.find((r) => storiesOf(r).includes(input.id));
|
|
86
|
+
if (!from)
|
|
87
|
+
throw new Error(`${input.id} is not in any release in storymap.yaml`);
|
|
88
|
+
const fromNumber = numberOf(from);
|
|
89
|
+
if (fromNumber === input.to)
|
|
90
|
+
throw new Error(`${input.id} is already in release ${input.to}`);
|
|
91
|
+
const seq = from.get("stories", true);
|
|
92
|
+
seq.items.splice(storiesOf(from).indexOf(input.id), 1);
|
|
93
|
+
const dest = target.get("stories", true);
|
|
94
|
+
if (isSeq(dest))
|
|
95
|
+
dest.add(input.id);
|
|
96
|
+
else
|
|
97
|
+
target.set("stories", o.map.createNode([input.id], { flow: true }));
|
|
98
|
+
retag(o.intentDir, new Map([[input.id, input.to]]));
|
|
99
|
+
return { id: input.id, from: fromNumber, to: input.to, validation: save(o) };
|
|
100
|
+
}
|
|
101
|
+
/** Move a release to a new position and renumber them all from 1, retagging every story that moved. */
|
|
102
|
+
export function reorderRelease(input, dir) {
|
|
103
|
+
const o = open(dir);
|
|
104
|
+
const r = find(o, input.number);
|
|
105
|
+
const count = o.releases.items.length;
|
|
106
|
+
if (!Number.isInteger(input.to) || input.to < 1 || input.to > count)
|
|
107
|
+
throw new Error(`Position must be from 1 to ${count}`);
|
|
108
|
+
const items = o.releases.items;
|
|
109
|
+
items.splice(items.indexOf(r), 1);
|
|
110
|
+
items.splice(input.to - 1, 0, r);
|
|
111
|
+
const moved = new Map();
|
|
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);
|
|
120
|
+
return { order: items.map((i) => ({ number: numberOf(i), title: String(i.get("title")) })), validation: save(o) };
|
|
121
|
+
}
|
|
122
|
+
const USAGE = `Usage:
|
|
123
|
+
nikos release add "<title>" [--goal "<goal>"]
|
|
124
|
+
nikos release rename <n> ["<title>"] [--goal "<goal>"] (--goal "" removes it)
|
|
125
|
+
nikos release move <story-id> <n>
|
|
126
|
+
nikos release reorder <n> <position>`;
|
|
127
|
+
function report(v) {
|
|
128
|
+
if (v.errors.length)
|
|
129
|
+
console.log(`\n! intent/ now has ${v.errors.length} ${v.errors.length === 1 ? "problem" : "problems"}; run nikos validate to see them.`);
|
|
130
|
+
}
|
|
131
|
+
export function runRelease(args) {
|
|
132
|
+
const [sub, ...rest] = args;
|
|
133
|
+
const opt = (n) => {
|
|
134
|
+
const i = rest.indexOf(`--${n}`);
|
|
135
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
136
|
+
};
|
|
137
|
+
const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && rest[i - 1].startsWith("--")));
|
|
138
|
+
const dir = opt("dir") ?? process.cwd();
|
|
139
|
+
try {
|
|
140
|
+
if (sub === "add" && positional[0]) {
|
|
141
|
+
const r = addRelease({ title: positional[0], goal: opt("goal") }, dir);
|
|
142
|
+
console.log(`Added release ${r.number}: ${positional[0].trim()}`);
|
|
143
|
+
report(r.validation);
|
|
144
|
+
}
|
|
145
|
+
else if (sub === "rename" && positional[0]) {
|
|
146
|
+
const r = renameRelease({ number: Number(positional[0]), title: positional[1], goal: opt("goal") }, dir);
|
|
147
|
+
console.log(`Updated release ${r.number}`);
|
|
148
|
+
report(r.validation);
|
|
149
|
+
}
|
|
150
|
+
else if (sub === "move" && positional[0] && positional[1]) {
|
|
151
|
+
const r = moveStory({ id: positional[0], to: Number(positional[1]) }, dir);
|
|
152
|
+
console.log(`Moved ${r.id} from release ${r.from} to release ${r.to}`);
|
|
153
|
+
report(r.validation);
|
|
154
|
+
}
|
|
155
|
+
else if (sub === "reorder" && positional[0] && positional[1]) {
|
|
156
|
+
const r = reorderRelease({ number: Number(positional[0]), to: Number(positional[1]) }, dir);
|
|
157
|
+
console.log(`Releases are now:\n${r.order.map((x) => ` ${x.number}. ${x.title}`).join("\n")}`);
|
|
158
|
+
report(r.validation);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
console.error(USAGE);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(`✗ ${err.message}`);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
package/dist/commands/show.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse } from "yaml";
|
|
4
|
-
import { STORY_ID, findIntentDir, read, rel
|
|
4
|
+
import { STORY_ID, findIntentDir, read, rel } from "../lib/project.js";
|
|
5
5
|
import { parseFeature, scenariosOf } from "../lint/gherkin.js";
|
|
6
6
|
import { featureFor } from "./clear.js";
|
|
7
7
|
import { planVersions } from "./plan.js";
|
|
8
|
-
import {
|
|
8
|
+
import { parseFrontMatter } from "../lib/frontmatter.js";
|
|
9
|
+
import { BET_ID, betsFor } from "../lib/bets.js";
|
|
9
10
|
import { decisionEntries } from "./decisions.js";
|
|
10
11
|
const yamlFile = (path) => (existsSync(path) ? parse(read(path)) : undefined);
|
|
11
12
|
/** Everything the intent/ folder says about one story, in plain text. */
|
|
@@ -48,16 +49,15 @@ export function show(story, dir) {
|
|
|
48
49
|
out.push("");
|
|
49
50
|
if (versions.length) {
|
|
50
51
|
const v = versions.at(-1);
|
|
51
|
-
const plan =
|
|
52
|
+
const plan = parseFrontMatter(read(join(intentDir, "plans", `${story}.v${v}.md`)));
|
|
52
53
|
out.push(`Plan: v${v} (intent/plans/${story}.v${v}.md)${plan?.meta?.summary ? ` — ${plan.meta.summary}` : ""}`);
|
|
53
54
|
}
|
|
54
55
|
else {
|
|
55
56
|
out.push("Plan: none yet");
|
|
56
57
|
}
|
|
57
|
-
const bets =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
out.push(bets.length ? `Bets: ${bets.map((b) => `${b.id} (${b.status}, ends ${b.time_box}, intent/bets/${b.id}.md)`).join(", ")}` : "Bets: none");
|
|
58
|
+
const { bets, from, release: rn } = betsFor(map, story);
|
|
59
|
+
const where = (b) => (BET_ID.test(b) ? `${b} (planning/bets/${b}.md)` : b);
|
|
60
|
+
out.push(bets.length ? `Bets: ${bets.map(where).join(", ")} — from ${from === "story" ? "the story" : `release ${rn}`}` : "Bets: none");
|
|
61
61
|
const decisions = decisionEntries(dir).filter((e) => e.title.includes(`(${story})`));
|
|
62
62
|
out.push(decisions.length ? "Decisions:" : "Decisions: none recorded for this story");
|
|
63
63
|
for (const d of decisions)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isMap, isSeq, parseDocument } from "yaml";
|
|
4
|
+
import { findIntentDir, read } from "../lib/project.js";
|
|
5
|
+
import { slugify } from "./init.js";
|
|
6
|
+
import { validate } from "./validate.js";
|
|
7
|
+
const yamlOpts = { lineWidth: 0, flowCollectionPadding: false };
|
|
8
|
+
/** Add a step to an activity in storymap.yaml. Its id is the title's slug unless given. */
|
|
9
|
+
export function newStep(input, dir) {
|
|
10
|
+
const title = input.title?.trim();
|
|
11
|
+
if (!title)
|
|
12
|
+
throw new Error("A step needs a title");
|
|
13
|
+
const intentDir = findIntentDir(dir);
|
|
14
|
+
const mapPath = join(intentDir, "storymap.yaml");
|
|
15
|
+
// A Document keeps comments and the order of everything we don't touch.
|
|
16
|
+
const map = parseDocument(read(mapPath));
|
|
17
|
+
const activities = map.get("activities", true);
|
|
18
|
+
const list = isSeq(activities) ? activities.items.filter(isMap) : [];
|
|
19
|
+
const activity = list.find((a) => a.get("id") === input.activity);
|
|
20
|
+
if (!activity)
|
|
21
|
+
throw new Error(`'${input.activity}' is not an activity in storymap.yaml. Activities: ${list.map((a) => a.get("id")).join(", ") || "none"}`);
|
|
22
|
+
const id = input.id ?? slugify(title);
|
|
23
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(id))
|
|
24
|
+
throw new Error(`'${id}' is not a step id: use lowercase words joined by hyphens`);
|
|
25
|
+
const taken = list.flatMap((a) => {
|
|
26
|
+
const steps = a.get("steps", true);
|
|
27
|
+
return isSeq(steps) ? steps.items.filter(isMap).map((s) => String(s.get("id"))) : [];
|
|
28
|
+
});
|
|
29
|
+
if (taken.includes(id))
|
|
30
|
+
throw new Error(`There is already a step '${id}'; give it a different --id`);
|
|
31
|
+
const steps = activity.get("steps", true);
|
|
32
|
+
if (isSeq(steps))
|
|
33
|
+
steps.add(map.createNode({ id, title }));
|
|
34
|
+
else
|
|
35
|
+
activity.set("steps", map.createNode([{ id, title }]));
|
|
36
|
+
writeFileSync(mapPath, map.toString(yamlOpts));
|
|
37
|
+
return { id, validation: validate(intentDir) };
|
|
38
|
+
}
|
|
39
|
+
export function runStep(args) {
|
|
40
|
+
const [sub, title, ...rest] = args;
|
|
41
|
+
const opt = (n) => {
|
|
42
|
+
const i = rest.indexOf(`--${n}`);
|
|
43
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
44
|
+
};
|
|
45
|
+
if (sub !== "add" || !title || title.startsWith("--") || !opt("activity")) {
|
|
46
|
+
console.error('Usage: nikos step add "<title>" --activity <id> [--id <step-id>]');
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const r = newStep({ title, activity: opt("activity"), id: opt("id") }, opt("dir") ?? process.cwd());
|
|
51
|
+
console.log(`Added step ${r.id} to activity ${opt("activity")}`);
|
|
52
|
+
console.log(`\nTag a story's feature with @step-${r.id}, or add one with nikos story add "<title>" --activity ${opt("activity")} --step ${r.id}.`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error(`✗ ${err.message}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
}
|