nikos 0.1.1 → 0.2.1

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 +46 -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 +19 -0
  7. package/dist/commands/ci.js +174 -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 +62 -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 +16 -0
@@ -1,12 +1,16 @@
1
1
  // The story map: activities as columns, releases as numbered bands, stories as cards tinted
2
2
  // by state.yaml. Read-only and script-free: the page has nothing to edit and runs no code, so
3
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";
4
+ // Served live by the planning dashboard, it also gets a filter bar (see FILTER_JS).
5
+ import { loadStates } from "../lib/state-files.js";
6
+ import { writeFileSync } from "node:fs";
5
7
  import { join, relative, resolve } from "node:path";
6
8
  import { parse } from "yaml";
7
- import { featureStoryIds, findIntentDir, read, walk } from "../lib/project.js";
9
+ import { approvalLapse, loadApprovals } from "../lib/approvals.js";
10
+ import { store } from "../lib/store.js";
11
+ import { featureStoryIds, featureTags, findIntentDir, isDone, read, specHash, walk } from "../lib/project.js";
8
12
  export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
9
- const yamlAt = (path) => (existsSync(path) ? parse(read(path)) : undefined);
13
+ const yamlAt = (path) => (store().exists(path) ? parse(read(path)) : undefined);
10
14
  const PRIORITY = /@(must|should|could|wont)\b/;
11
15
  /** One story's details from its feature file: title, tags, narrative and criteria. */
12
16
  function featureStory(text, id) {
@@ -42,23 +46,47 @@ function featureStory(text, id) {
42
46
  effort: /@effort-([A-Za-z0-9]+)/.exec(head)?.[1] ?? "",
43
47
  narrative: narrative.map((l, i) => (i ? l.replace(/^So that\b/, "so that") : l)).join(", "),
44
48
  criteria,
49
+ type: { technical: "technical", "bug-risk": "bug_risk" }[featureTags(text, "type")[0] ?? ""] ?? "user_story",
50
+ personas: featureTags(text, "persona"),
51
+ dependsOn: featureTags(text, "depends"),
45
52
  };
46
53
  }
47
54
  /** The story map as data: activities, releases with their bets, and every story's status. */
48
55
  export function storyMapOf(dir) {
49
56
  const intent = findIntentDir(dir);
50
57
  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 });
58
+ const states = loadStates(intent);
59
+ const approvals = loadApprovals(intent);
60
+ // Approved is shown as a status: done, plus an approval that still holds.
61
+ const approvedBy = (id, hash) => {
62
+ const a = approvals[id];
63
+ return a && !approvalLapse(a, states[id], hash) ? a.by : "";
64
+ };
65
+ const stateOf = (id, deps, hash) => {
66
+ const st = states[id] ?? {};
67
+ const by = approvedBy(id, hash);
68
+ const status = by ? "approved" : st.status ?? "todo";
69
+ return {
70
+ status,
71
+ blocked: (st.blockers ?? []).length > 0,
72
+ waitingOn: isDone(status) || status === "cancelled" ? [] : deps.filter((d) => !isDone(states[d]?.status)),
73
+ approvedBy: by,
74
+ specChanged: isDone(status) && !!st.spec_hash && !!hash && st.spec_hash !== hash,
75
+ cancelledReason: String(st.cancelled_reason ?? ""),
76
+ };
77
+ };
53
78
  const features = new Map();
54
79
  for (const file of walk(join(intent, "features"), ".feature")) {
55
80
  const text = read(file);
56
81
  const id = featureStoryIds(text)[0];
57
82
  if (!id)
58
83
  continue;
59
- features.set(id, { ...featureStory(text, id), ...stateOf(id) });
84
+ const f = featureStory(text, id);
85
+ features.set(id, { ...f, ...stateOf(id, f.dependsOn, specHash(text)) });
60
86
  }
61
- const story = (id) => features.get(id) ?? { id, title: id, activity: "", step: "", priority: "", effort: "", narrative: "", criteria: [], ...stateOf(id) };
87
+ const story = (id) => features.get(id) ?? {
88
+ id, title: id, activity: "", step: "", priority: "", effort: "", narrative: "", criteria: [], type: "user_story", personas: [], dependsOn: [], ...stateOf(id, []),
89
+ };
62
90
  const releases = (Array.isArray(map.releases) ? map.releases : []).map((r) => ({
63
91
  number: Number(r.number),
64
92
  title: String(r.title ?? `Release ${r.number}`),
@@ -69,6 +97,8 @@ export function storyMapOf(dir) {
69
97
  const placed = new Set(releases.flatMap((r) => r.stories.map((s) => s.id)));
70
98
  return {
71
99
  project: String(map.project ?? ""),
100
+ key: String(map.key ?? ""),
101
+ personas: (Array.isArray(map.personas) ? map.personas : []).map((p) => ({ id: String(p.id), title: String(p.title ?? p.id), description: String(p.description ?? "") })),
72
102
  activities: (Array.isArray(map.activities) ? map.activities : []).map((a) => ({
73
103
  id: String(a.id),
74
104
  title: String(a.title ?? a.id),
@@ -78,17 +108,23 @@ export function storyMapOf(dir) {
78
108
  unreleased: [...features.values()].filter((s) => !placed.has(s.id)),
79
109
  };
80
110
  }
81
- /** Each status's tint class and the word shown on its card. Blocked wins over the status. */
111
+ /**
112
+ * Each status's tint class and the word shown on its card. Blocked wins over the status, and a
113
+ * changed spec wins over done or approved: what was proved is no longer what the feature says.
114
+ */
82
115
  const STATUS = {
83
116
  todo: { cls: "todo", word: "to do" },
84
117
  in_progress: { cls: "doing", word: "in progress" },
85
118
  needs_rework: { cls: "rework", word: "rework" },
86
119
  done: { cls: "done", word: "done" },
120
+ approved: { cls: "approved", word: "approved" },
121
+ cancelled: { cls: "cancelled", word: "cancelled" },
87
122
  };
88
- const statusOf = (s) => (s.blocked ? { cls: "blocked", word: "blocked" } : STATUS[s.status] ?? STATUS.todo);
123
+ const statusOf = (s) => s.blocked ? { cls: "blocked", word: "blocked" } : s.specChanged ? { cls: "changed", word: "spec changed" } : STATUS[s.status] ?? STATUS.todo;
124
+ const TYPE_WORD = { technical: "Technical", bug_risk: "Bug risk" };
89
125
  const pad = (n) => String(n).padStart(2, "0");
90
- /** The whole story map as one standalone HTML page. */
91
- export function renderMap(dir) {
126
+ /** The whole story map as one standalone HTML page. live adds the dashboard's filter bar and its script. */
127
+ export function renderMap(dir, opts = {}) {
92
128
  const m = storyMapOf(dir);
93
129
  // One column per step; an activity with no steps (or the "Other" bucket) gets one column.
94
130
  const known = new Set(m.activities.map((a) => a.id));
@@ -106,24 +142,37 @@ export function renderMap(dir) {
106
142
  const card = (s) => {
107
143
  const st = statusOf(s);
108
144
  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>`;
145
+ const foot = s.waitingOn.length
146
+ ? `<span class="wait">Waits on ${esc(s.waitingOn.join(", "))}</span>`
147
+ : s.criteria.length
148
+ ? `<span>${s.criteria.length} ${s.criteria.length === 1 ? "criterion" : "criteria"}${musts ? ` · ${musts} must` : ""}</span>`
149
+ : `<span class="warn">No criteria</span>`;
150
+ const type = TYPE_WORD[s.type] ? `<span class="stype">${TYPE_WORD[s.type]}</span>` : "";
151
+ // data- attributes let the live dashboard filter cards; the exported page ignores them.
152
+ const data = `data-story="${esc(s.id)}" data-status="${esc(st.cls)}" data-type="${esc(s.type)}" data-personas="${esc(s.personas.join(" "))}" data-search="${esc(`${s.id} ${s.title} ${s.narrative}`.toLowerCase())}"`;
153
+ return `<a class="card ${st.cls}" ${data} href="#story-${esc(s.id)}"><span class="crow"><span class="sid">${esc(s.id)}</span>${type}<span class="sword">${esc(st.word)}</span></span><span class="stitle">${esc(s.title)}</span><span class="cfoot">${foot}</span></a>`;
113
154
  };
114
155
  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>` : "");
156
+ const live = stories.filter((s) => s.status !== "cancelled");
157
+ const n = (f) => live.filter(f).length;
158
+ const approved = n((s) => s.status === "approved");
159
+ const parts = [
160
+ `${n((s) => isDone(s.status))} of ${live.length} done`,
161
+ ...(approved ? [`${approved} approved`] : []),
162
+ `${n((s) => s.status === "in_progress")} in progress`,
163
+ `${n((s) => s.status === "todo")} to do`,
164
+ ];
165
+ const warn = [[n((s) => s.blocked), "blocked"], [n((s) => s.specChanged), "spec changed"]];
166
+ return parts.join(" · ") + warn.map(([k, w]) => (k ? ` · <span class="warn">${k} ${w}</span>` : "")).join("");
119
167
  };
120
168
  const band = (id, code, heading, goal, bets, stories) => {
121
- const shipped = stories.length > 0 && stories.every((s) => s.status === "done");
169
+ const live = stories.filter((s) => s.status !== "cancelled");
170
+ const shipped = live.length > 0 && live.every((s) => isDone(s.status) && !s.specChanged);
122
171
  const chips = bets.map((b) => `<span class="bet">${esc(b)}</span>`).join("");
123
172
  const cells = columns
124
173
  .map((c) => `<div class="cell" data-activity="${esc(c.label)}">${stories.filter((s) => columnOf(s) === c.key).map(card).join("")}</div>`)
125
174
  .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>`;
175
+ return `<section class="band" id="${esc(id)}" data-release="${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
176
  };
128
177
  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
178
  const unreleased = m.unreleased.length ? band("unreleased", "--", "Not in a release", "", [], m.unreleased) : "";
@@ -136,13 +185,22 @@ export function renderMap(dir) {
136
185
  ].join("");
137
186
  const panel = (s) => {
138
187
  const st = statusOf(s);
139
- const facts = [s.id, s.priority, s.effort && `effort ${s.effort}`].filter(Boolean).map((f) => esc(f)).join(" · ");
188
+ const facts = [s.id, TYPE_WORD[s.type], s.priority, s.effort && `effort ${s.effort}`].filter(Boolean).map((f) => esc(f)).join(" · ");
189
+ const persona = (id) => m.personas.find((p) => p.id === id)?.title ?? id;
190
+ const notes = [
191
+ s.personas.length && `For ${s.personas.map(persona).join(", ")}`,
192
+ s.dependsOn.length && `Depends on ${s.dependsOn.map((d) => (s.waitingOn.includes(d) ? `${d} (not done)` : d)).join(", ")}`,
193
+ s.status === "approved" && `Approved by ${s.approvedBy || "someone"}`,
194
+ s.status === "cancelled" && `Cancelled: ${s.cancelledReason}`,
195
+ s.specChanged && "The feature changed after it was done, so its scenarios no longer prove it",
196
+ ].filter(Boolean).map((n) => `<li>${esc(n)}</li>`).join("");
140
197
  const criteria = s.criteria.length
141
198
  ? `<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
199
  : `<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>`;
200
+ 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>` : ""}${notes ? `<ul class="notes">${notes}</ul>` : ""}</div><div class="eyebrow">Acceptance criteria</div>${criteria}</aside></div>`;
144
201
  };
145
202
  const title = m.project ? `${m.project} · Story map` : "Story map";
203
+ const filters = opts.live ? filterBar(m, everything) : "";
146
204
  return `<!doctype html>
147
205
  <html lang="en">
148
206
  <head>
@@ -153,7 +211,7 @@ export function renderMap(dir) {
153
211
  </head>
154
212
  <body>
155
213
  <header class="top"><h1>${esc(title)}</h1><nav class="jumps" aria-label="Releases">${jumps}</nav></header>
156
- <main>
214
+ ${filters}<main>
157
215
  <div class="board" style="--cols:${Math.max(1, columns.length)}">
158
216
  ${spine}
159
217
  ${bands}
@@ -161,10 +219,107 @@ ${unreleased}
161
219
  </div>
162
220
  </main>
163
221
  ${everything.map(panel).join("\n")}
222
+ ${opts.live ? `<script>${FILTER_JS}</script>` : ""}
164
223
  </body>
165
224
  </html>
166
225
  `;
167
226
  }
227
+ /** Search, release, status, type and persona filters, and the release-rows switch. Live page only. */
228
+ function filterBar(m, stories) {
229
+ const option = (value, text) => `<option value="${esc(value)}">${esc(text)}</option>`;
230
+ const statuses = [...new Set(stories.map((s) => statusOf(s)))].map((st) => st.cls);
231
+ const words = Object.fromEntries([...Object.values(STATUS), { cls: "blocked", word: "blocked" }, { cls: "changed", word: "spec changed" }].map((x) => [x.cls, x.word]));
232
+ const chips = [...new Set(statuses)]
233
+ .map((cls) => `<label class="chip ${esc(cls)}"><input type="checkbox" name="status" value="${esc(cls)}">${esc(words[cls] ?? cls)}</label>`)
234
+ .join("");
235
+ const types = [...new Set(stories.map((s) => s.type))].filter((t) => t !== "user_story");
236
+ return `<div class="filters" role="search">
237
+ <input type="search" id="f-q" placeholder="Search stories" aria-label="Search stories">
238
+ <select id="f-release" aria-label="Release">${option("", "All releases")}${m.releases.map((r) => option(`release-${r.number}`, `R${r.number} · ${r.title}`)).join("")}${m.unreleased.length ? option("unreleased", "Not in a release") : ""}</select>
239
+ ${types.length ? `<select id="f-type" aria-label="Story type">${option("", "All types")}${option("user_story", "User stories")}${types.map((t) => option(t, TYPE_WORD[t] ?? t)).join("")}</select>` : ""}
240
+ ${m.personas.length ? `<select id="f-persona" aria-label="Persona">${option("", "Everyone")}${m.personas.map((p) => option(p.id, p.title)).join("")}</select>` : ""}
241
+ <fieldset class="chips"><legend>Status</legend>${chips}</fieldset>
242
+ <label class="switch"><input type="checkbox" id="f-rows" checked>Release rows</label>
243
+ <span id="f-count" class="count" aria-live="polite"></span>
244
+ </div>
245
+ `;
246
+ }
247
+ // Runs only on the dashboard's live map. Filters hide cards; with release rows off, every
248
+ // release's cards are shown together in one band, column by column.
249
+ const FILTER_JS = `
250
+ (function () {
251
+ var board = document.querySelector('.board');
252
+ var bands = Array.prototype.slice.call(board.querySelectorAll('.band'));
253
+ var $ = function (id) { return document.getElementById(id); };
254
+ var merged = document.createElement('section');
255
+ merged.className = 'band';
256
+ merged.hidden = true;
257
+ merged.setAttribute('data-release', 'all');
258
+ var lane = document.createElement('div');
259
+ lane.className = 'lane';
260
+ var first = board.querySelector('.lane');
261
+ if (first) {
262
+ Array.prototype.forEach.call(first.children, function (cell, i) {
263
+ var c = document.createElement('div');
264
+ c.className = 'cell';
265
+ c.setAttribute('data-activity', cell.getAttribute('data-activity'));
266
+ bands.forEach(function (b) {
267
+ var cells = b.querySelectorAll('.lane > .cell');
268
+ if (cells[i]) Array.prototype.forEach.call(cells[i].children, function (card) { c.appendChild(card.cloneNode(true)); });
269
+ });
270
+ lane.appendChild(c);
271
+ });
272
+ }
273
+ merged.appendChild(lane);
274
+ board.appendChild(merged);
275
+
276
+ function value(id) { var el = $(id); return el ? el.value : ''; }
277
+ function apply() {
278
+ var q = value('f-q').trim().toLowerCase();
279
+ var release = value('f-release');
280
+ var type = value('f-type');
281
+ var persona = value('f-persona');
282
+ var rows = $('f-rows').checked;
283
+ var statuses = Array.prototype.map.call(document.querySelectorAll('input[name=status]:checked'), function (i) { return i.value; });
284
+ var filtering = q || type || persona || statuses.length;
285
+ var shown = 0, total = 0;
286
+ var match = function (card) {
287
+ return (!release || rows ? true : card.getAttribute('data-release-of') === release) &&
288
+ (!q || card.getAttribute('data-search').indexOf(q) >= 0) &&
289
+ (!type || card.getAttribute('data-type') === type) &&
290
+ (!persona || (' ' + card.getAttribute('data-personas') + ' ').indexOf(' ' + persona + ' ') >= 0) &&
291
+ (!statuses.length || statuses.indexOf(card.getAttribute('data-status')) >= 0);
292
+ };
293
+ bands.forEach(function (b) {
294
+ b.hidden = !rows || (release && b.getAttribute('data-release') !== release);
295
+ });
296
+ merged.hidden = rows;
297
+ var active = rows ? bands.filter(function (b) { return !b.hidden; }) : [merged];
298
+ active.forEach(function (b) {
299
+ var visible = 0;
300
+ Array.prototype.forEach.call(b.querySelectorAll('.card'), function (card) {
301
+ var ok = match(card);
302
+ card.hidden = !ok;
303
+ total++;
304
+ if (ok) { shown++; visible++; }
305
+ });
306
+ if (rows && filtering && !visible) b.hidden = true;
307
+ });
308
+ $('f-count').textContent = filtering || release ? shown + ' of ' + total + ' stories' : '';
309
+ }
310
+ // Remember which release each card came from, so the release filter works with rows off.
311
+ bands.forEach(function (b) {
312
+ Array.prototype.forEach.call(b.querySelectorAll('.card'), function (card) {
313
+ Array.prototype.forEach.call(merged.querySelectorAll('.card[data-story="' + card.getAttribute('data-story') + '"]'), function (copy) {
314
+ copy.setAttribute('data-release-of', b.getAttribute('data-release'));
315
+ });
316
+ });
317
+ });
318
+ document.querySelector('.filters').addEventListener('input', apply);
319
+ document.querySelector('.filters').addEventListener('change', apply);
320
+ apply();
321
+ })();
322
+ `;
168
323
  const CSS = `
169
324
  :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
325
  * { box-sizing: border-box; }
@@ -215,8 +370,28 @@ main { flex: 1; min-height: 0; overflow: auto; background-color: var(--paper); b
215
370
  .card.rework { border-left: 4px solid #E8892B; }
216
371
  .done { --fill: #F1EEFB; --edge: #DCD5F3; --word: #6D5BB8; }
217
372
  .card.done .stitle { text-decoration: line-through; text-decoration-color: rgba(26,26,24,.35); }
373
+ .approved { --fill: #EAF6EE; --edge: #C7E6D2; --word: #1E7A50; }
374
+ .card.approved .stitle { text-decoration: line-through; text-decoration-color: rgba(26,26,24,.35); }
375
+ .changed { --fill: #FFF6E5; --edge: #F2D9A6; --word: #B45309; }
376
+ .card.changed { border-left: 4px solid #D97706; }
377
+ .cancelled { --fill: transparent; --edge: #E3E1D9; --word: #7D786E; }
378
+ .card.cancelled { border-style: dashed; box-shadow: none; opacity: .7; }
379
+ .card.cancelled .stitle { text-decoration: line-through; }
380
+ .stype { font-size: 11px; padding: 0 6px; border-radius: 99px; background: var(--elevated); color: var(--ink-2); }
381
+ .wait { color: var(--warn); }
382
+ .notes { margin: 10px 0 0; padding-left: 18px; color: var(--ink-2); }
218
383
  .blocked { --fill: #FDECEC; --edge: #F3C2C2; --word: #B83232; }
219
384
  .card.blocked { border-left: 4px solid #D64545; }
385
+ [hidden] { display: none !important; }
386
+ .filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 24px; border-bottom: 1px solid var(--line); background: var(--surface); }
387
+ .filters input[type=search], .filters select { height: 30px; border: 1px solid var(--line-2); border-radius: 6px; background: var(--surface); color: var(--ink); font: inherit; padding: 0 10px; }
388
+ .filters input[type=search] { width: 220px; max-width: 100%; }
389
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; border: 0; margin: 0; padding: 0; }
390
+ .chips legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
391
+ .chip { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; border: 1px solid var(--edge, var(--line)); border-radius: 99px; background: var(--fill, var(--surface)); color: var(--word, var(--ink)); font-size: 12px; cursor: pointer; }
392
+ .chip input { margin: 0; accent-color: var(--brand); }
393
+ .chip:has(input:checked) { outline: 2px solid var(--brand); outline-offset: -1px; }
394
+ .switch { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2); }
220
395
  .sheet-wrap { display: none; }
221
396
  .sheet-wrap:target { display: block; position: fixed; inset: 0; z-index: 10; }
222
397
  .scrim { position: absolute; inset: 0; background: rgba(0,0,0,.4); }
@@ -237,6 +412,8 @@ main { flex: 1; min-height: 0; overflow: auto; background-color: var(--paper); b
237
412
  @media (max-width: 767px) {
238
413
  body { height: auto; display: block; }
239
414
  .top { padding: 12px 16px; }
415
+ .filters { padding: 10px 16px; }
416
+ .filters input[type=search] { width: 100%; }
240
417
  main { background-image: none; }
241
418
  .board { width: auto; padding: 0 16px 32px; }
242
419
  .spine { display: none; }
@@ -258,7 +435,7 @@ export function runMap(args) {
258
435
  const out = resolve(flag("--out") ?? "storymap.html");
259
436
  const dir = resolve(flag("--dir") ?? process.cwd());
260
437
  try {
261
- if (!existsSync(join(findIntentDir(dir), "storymap.yaml")))
438
+ if (!store().exists(join(findIntentDir(dir), "storymap.yaml")))
262
439
  throw new Error(`No storymap.yaml in ${findIntentDir(dir)}: run nikos init first`);
263
440
  writeFileSync(out, renderMap(dir));
264
441
  const shown = relative(process.cwd(), out);
@@ -0,0 +1,10 @@
1
+ export type Layout = "per-story" | "single-file";
2
+ export interface MigrateResult {
3
+ layout: Layout;
4
+ states: number;
5
+ approvals: number;
6
+ /** The CODEOWNERS file, when a rule for approvals was carried over to the new layout. */
7
+ codeowners?: string;
8
+ }
9
+ export declare function migrate(dir: string, to: Layout): MigrateResult;
10
+ export declare function runMigrate(args: string[]): number;
@@ -0,0 +1,105 @@
1
+ // `nikos migrate per-story`: move every story's state and approval to its own file, so people
2
+ // and agents working on different stories in parallel branches never edit the same file.
3
+ // intent/state.yaml keeps format_version and the run queue; each story gets intent/state/<ID>.yaml
4
+ // and, once approved, intent/approvals/<ID>.yaml. `nikos migrate single-file` moves them back.
5
+ // Both layouts read and write the same through lib/state-files.ts.
6
+ import { dirname, join } from "node:path";
7
+ import { parse, parseDocument, stringify } from "yaml";
8
+ import { findIntentDir, read } from "../lib/project.js";
9
+ import { approvalsDir, approvalsFile, stateDir, statePath, storyApprovalPath, storyFiles, storyStatePath } from "../lib/state-files.js";
10
+ import { store } from "../lib/store.js";
11
+ import { validate } from "./validate.js";
12
+ const CODEOWNERS = ["CODEOWNERS", ".github/CODEOWNERS", ".gitlab/CODEOWNERS", "docs/CODEOWNERS"];
13
+ /** Give a CODEOWNERS rule on approvals.yaml a matching rule on approvals/, or the other way round. */
14
+ function carryCodeowners(root, to) {
15
+ const file = CODEOWNERS.map((f) => join(root, f)).find((f) => store().exists(f));
16
+ if (!file)
17
+ return undefined;
18
+ const text = read(file);
19
+ const [from, into] = to === "per-story" ? [/^(\S*intent\/)approvals\.yaml(\s+.+)$/m, "approvals/"] : [/^(\S*intent\/)approvals\/(\s+.+)$/m, "approvals.yaml"];
20
+ const m = text.match(from);
21
+ if (!m)
22
+ return undefined;
23
+ const line = `${m[1]}${into}${m[2]}`;
24
+ if (text.split("\n").includes(line))
25
+ return undefined;
26
+ store().write(file, `${text}${text.endsWith("\n") ? "" : "\n"}${line}\n`);
27
+ return file;
28
+ }
29
+ export function migrate(dir, to) {
30
+ const intent = findIntentDir(dir);
31
+ if (!store().exists(statePath(intent)))
32
+ throw new Error("No intent/state.yaml here: set nikOS up first (nikos init)");
33
+ const before = validate(intent);
34
+ if (before.errors.length)
35
+ throw new Error(`Fix intent/ first (nikos validate lists ${before.errors.length} ${before.errors.length === 1 ? "problem" : "problems"}), so nothing is lost moving it`);
36
+ const doc = parseDocument(read(statePath(intent)));
37
+ let states = 0;
38
+ let approvals = 0;
39
+ if (to === "per-story") {
40
+ const stories = doc.toJS()?.stories ?? {};
41
+ for (const [id, entry] of Object.entries(stories)) {
42
+ store().write(storyStatePath(intent, id), stringify(entry));
43
+ states++;
44
+ }
45
+ doc.set("stories", doc.createNode({}));
46
+ doc.commentBefore = `${doc.commentBefore ? `${doc.commentBefore}\n` : ""} Each story's state is in intent/state/<ID>.yaml.`;
47
+ store().write(statePath(intent), doc.toString({ flowCollectionPadding: false }));
48
+ if (store().exists(approvalsFile(intent))) {
49
+ for (const [id, a] of Object.entries(parse(read(approvalsFile(intent)))?.approvals ?? {})) {
50
+ store().write(storyApprovalPath(intent, id), stringify(a));
51
+ approvals++;
52
+ }
53
+ store().remove(approvalsFile(intent));
54
+ }
55
+ }
56
+ else {
57
+ for (const f of storyFiles(stateDir(intent))) {
58
+ doc.setIn(["stories", f.id], doc.createNode(parse(read(f.path))));
59
+ store().remove(f.path);
60
+ states++;
61
+ }
62
+ if (doc.commentBefore)
63
+ doc.commentBefore = doc.commentBefore.replace(/\n? Each story's state is in intent\/state\/<ID>\.yaml\./, "") || null;
64
+ store().write(statePath(intent), doc.toString({ flowCollectionPadding: false }));
65
+ const files = storyFiles(approvalsDir(intent));
66
+ if (files.length) {
67
+ const path = approvalsFile(intent);
68
+ const adoc = parseDocument(store().exists(path) ? read(path) : "# Sign-offs on done stories, written by nikos approve.\nformat_version: 1\napprovals: {}\n");
69
+ for (const f of files) {
70
+ adoc.setIn(["approvals", f.id], adoc.createNode(parse(read(f.path))));
71
+ store().remove(f.path);
72
+ approvals++;
73
+ }
74
+ store().write(path, adoc.toString({ flowCollectionPadding: false }));
75
+ }
76
+ }
77
+ const after = validate(intent);
78
+ if (after.errors.length)
79
+ throw new Error(`The move left problems in intent/; check it with nikos validate:\n${after.errors.map((e) => ` ${e.file}: ${e.message}`).join("\n")}`);
80
+ const codeowners = carryCodeowners(dirname(intent), to);
81
+ return { layout: to, states, approvals, ...(codeowners ? { codeowners } : {}) };
82
+ }
83
+ export function runMigrate(args) {
84
+ const i = args.indexOf("--dir");
85
+ const dir = i >= 0 ? args[i + 1] : process.cwd();
86
+ const to = args.find((a, n) => !a.startsWith("--") && (i < 0 || n !== i + 1));
87
+ if (to !== "per-story" && to !== "single-file") {
88
+ console.error("Usage: nikos migrate per-story | single-file [--dir <path>]\n per-story each story's state and approval in its own file, so parallel branches don't conflict\n single-file back to intent/state.yaml and intent/approvals.yaml");
89
+ return 1;
90
+ }
91
+ try {
92
+ const r = migrate(dir, to);
93
+ console.log(r.layout === "per-story"
94
+ ? `Moved ${r.states} stories to intent/state/<ID>.yaml${r.approvals ? ` and ${r.approvals} approvals to intent/approvals/<ID>.yaml` : ""}.`
95
+ : `Moved ${r.states} stories back to intent/state.yaml${r.approvals ? ` and ${r.approvals} approvals to intent/approvals.yaml` : ""}.`);
96
+ if (r.codeowners)
97
+ console.log(`Added a matching approvals rule to ${r.codeowners}.`);
98
+ console.log("Commit the change in one go, on a branch nobody else is moving stories on.");
99
+ return 0;
100
+ }
101
+ catch (err) {
102
+ console.error(`✗ ${err.message}`);
103
+ return 1;
104
+ }
105
+ }
@@ -0,0 +1,19 @@
1
+ export interface NextResult {
2
+ /** The story to build next, or undefined when nothing is left to do. */
3
+ story?: string;
4
+ /** Stories already started; finish these before starting something new. */
5
+ open: {
6
+ id: string;
7
+ status: string;
8
+ }[];
9
+ /** Todo stories passed over because a story they depend on isn't done yet. */
10
+ waiting: {
11
+ id: string;
12
+ on: string[];
13
+ }[];
14
+ }
15
+ /** Each story's @depends-<id> tags, from its feature file. */
16
+ export declare function dependencies(intentDir: string): Map<string, string[]>;
17
+ /** The first todo story in run.queue, then in story map order, whose dependencies are all done. */
18
+ export declare function next(dir: string): NextResult;
19
+ export declare function runNext(args: string[]): number;
@@ -1,19 +1,41 @@
1
1
  import { join } from "node:path";
2
+ import { loadStates } from "../lib/state-files.js";
2
3
  import { parse } from "yaml";
3
- import { findIntentDir, read } from "../lib/project.js";
4
- /** The first todo story in run.queue, then in story map order. */
4
+ import { featureStoryIds, featureTags, findIntentDir, isDone, read, walk } from "../lib/project.js";
5
+ /** Each story's @depends-<id> tags, from its feature file. */
6
+ export function dependencies(intentDir) {
7
+ const deps = new Map();
8
+ for (const file of walk(join(intentDir, "features"), ".feature")) {
9
+ const text = read(file);
10
+ const id = featureStoryIds(text)[0];
11
+ if (id)
12
+ deps.set(id, featureTags(text, "depends"));
13
+ }
14
+ return deps;
15
+ }
16
+ /** The first todo story in run.queue, then in story map order, whose dependencies are all done. */
5
17
  export function next(dir) {
6
18
  const intentDir = findIntentDir(dir);
7
19
  const map = parse(read(join(intentDir, "storymap.yaml")));
8
20
  const state = parse(read(join(intentDir, "state.yaml")));
9
- const stories = state?.stories ?? {};
21
+ const stories = loadStates(intentDir);
10
22
  const mapOrder = (map?.releases ?? [])
11
23
  .slice()
12
24
  .sort((a, b) => a.number - b.number)
13
25
  .flatMap((r) => r.stories ?? []);
14
26
  const order = [...new Set([...(state?.run?.queue ?? []), ...mapOrder])];
15
27
  const open = order.filter((id) => ["in_progress", "needs_rework"].includes(stories[id]?.status ?? "")).map((id) => ({ id, status: stories[id].status }));
16
- return { story: order.find((id) => (stories[id]?.status ?? "todo") === "todo"), open };
28
+ const deps = dependencies(intentDir);
29
+ const waiting = [];
30
+ for (const id of order) {
31
+ if ((stories[id]?.status ?? "todo") !== "todo")
32
+ continue;
33
+ const on = (deps.get(id) ?? []).filter((d) => !isDone(stories[d]?.status));
34
+ if (!on.length)
35
+ return { story: id, open, waiting };
36
+ waiting.push({ id, on });
37
+ }
38
+ return { open, waiting };
17
39
  }
18
40
  export function runNext(args) {
19
41
  const i = args.indexOf("--dir");
@@ -22,7 +44,9 @@ export function runNext(args) {
22
44
  for (const o of r.open)
23
45
  console.log(`! ${o.id} is ${o.status}; finish it first`);
24
46
  if (!r.story) {
25
- console.log("Nothing left to do: every story is started or done.");
47
+ for (const w of r.waiting)
48
+ console.log(`! ${w.id} is waiting on ${w.on.join(", ")}`);
49
+ console.log(r.waiting.length ? "Nothing is ready: every todo story waits on another." : "Nothing left to do: every story is started or done.");
26
50
  return 0;
27
51
  }
28
52
  console.log(r.story);
@@ -0,0 +1,8 @@
1
+ export interface NewPlanOptions {
2
+ dir?: string;
3
+ summary?: string;
4
+ date?: string;
5
+ }
6
+ export declare function planVersions(intentDir: string, story: string): number[];
7
+ export declare function newPlan(story: string, opts?: NewPlanOptions): string;
8
+ export declare function runPlan(args: string[]): number;
@@ -1,16 +1,17 @@
1
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { parse } from "yaml";
5
5
  import { STORY_ID, findIntentDir, read } from "../lib/project.js";
6
+ import { store } from "../lib/store.js";
6
7
  const here = dirname(fileURLToPath(import.meta.url));
7
8
  const templateDir = [join(here, "../../templates"), join(here, "../templates")].find((d) => existsSync(d));
8
9
  export function planVersions(intentDir, story) {
9
10
  const plans = join(intentDir, "plans");
10
- if (!existsSync(plans))
11
+ if (!store().exists(plans))
11
12
  return [];
12
13
  const re = new RegExp(`^${story}\\.v(\\d+)\\.md$`);
13
- return readdirSync(plans)
14
+ return store().list(plans)
14
15
  .map((f) => re.exec(f)?.[1])
15
16
  .filter((v) => !!v)
16
17
  .map(Number)
@@ -21,7 +22,7 @@ export function newPlan(story, opts = {}) {
21
22
  throw new Error(`'${story}' is not a story id (expected e.g. HT-1)`);
22
23
  const intentDir = findIntentDir(opts.dir ?? process.cwd());
23
24
  const mapPath = join(intentDir, "storymap.yaml");
24
- if (existsSync(mapPath)) {
25
+ if (store().exists(mapPath)) {
25
26
  const map = parse(read(mapPath));
26
27
  const known = (map?.releases ?? []).some((r) => (r?.stories ?? []).includes(story));
27
28
  if (!known)
@@ -30,16 +31,16 @@ export function newPlan(story, opts = {}) {
30
31
  const versions = planVersions(intentDir, story);
31
32
  const version = (versions.at(-1) ?? 0) + 1;
32
33
  const path = join(intentDir, "plans", `${story}.v${version}.md`);
33
- const content = read(join(templateDir, "plan.md"))
34
+ // The template ships with nikOS, so it's always read from disk.
35
+ const content = readFileSync(join(templateDir, "plan.md"), "utf8")
34
36
  .replace("{{story}}", story)
35
37
  .replace("{{version}}", String(version))
36
38
  .replace("{{date}}", opts.date ?? new Date().toISOString().slice(0, 10))
37
39
  .replace("{{supersedes}}", version > 1 ? `supersedes: ${version - 1}\n` : "")
38
40
  .replace("{{summary}}", opts.summary ?? "One to three plain sentences on the approach.")
39
41
  .replace("{{first_step}}", "First step, in eight words or fewer");
40
- mkdirSync(dirname(path), { recursive: true });
41
- // "wx" fails if the file exists, so an earlier plan can never be overwritten.
42
- writeFileSync(path, content, { flag: "wx" });
42
+ // create fails if the file exists, so an earlier plan can never be overwritten.
43
+ store().write(path, content, { create: true });
43
44
  return path;
44
45
  }
45
46
  export function runPlan(args) {