nikos 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +45 -9
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +49 -1
  4. package/dist/commands/approve.d.ts +16 -0
  5. package/dist/commands/approve.js +120 -0
  6. package/dist/commands/ci.d.ts +18 -0
  7. package/dist/commands/ci.js +123 -0
  8. package/dist/commands/clear.d.ts +14 -0
  9. package/dist/commands/decide.d.ts +11 -0
  10. package/dist/commands/decide.js +3 -3
  11. package/dist/commands/decisions.d.ts +14 -0
  12. package/dist/commands/decisions.js +2 -2
  13. package/dist/commands/doctor.d.ts +11 -0
  14. package/dist/commands/doctor.js +92 -0
  15. package/dist/commands/export.d.ts +48 -0
  16. package/dist/commands/export.js +164 -0
  17. package/dist/commands/init.d.ts +16 -0
  18. package/dist/commands/init.js +8 -12
  19. package/dist/commands/join.d.ts +17 -0
  20. package/dist/commands/join.js +91 -0
  21. package/dist/commands/map.d.ts +65 -0
  22. package/dist/commands/map.js +202 -25
  23. package/dist/commands/migrate.d.ts +10 -0
  24. package/dist/commands/migrate.js +105 -0
  25. package/dist/commands/next.d.ts +19 -0
  26. package/dist/commands/next.js +29 -5
  27. package/dist/commands/plan.d.ts +8 -0
  28. package/dist/commands/plan.js +9 -8
  29. package/dist/commands/release.d.ts +66 -0
  30. package/dist/commands/release.js +75 -6
  31. package/dist/commands/show.d.ts +3 -0
  32. package/dist/commands/show.js +26 -4
  33. package/dist/commands/start.d.ts +28 -0
  34. package/dist/commands/start.js +138 -0
  35. package/dist/commands/state.d.ts +27 -0
  36. package/dist/commands/state.js +40 -22
  37. package/dist/commands/step.d.ts +17 -0
  38. package/dist/commands/step.js +38 -2
  39. package/dist/commands/story.d.ts +21 -0
  40. package/dist/commands/story.js +7 -10
  41. package/dist/commands/suggest.d.ts +16 -0
  42. package/dist/commands/suggest.js +140 -0
  43. package/dist/commands/tracker.d.ts +60 -0
  44. package/dist/commands/tracker.js +185 -0
  45. package/dist/commands/validate.d.ts +15 -0
  46. package/dist/commands/validate.js +149 -14
  47. package/dist/commands/verify.d.ts +20 -0
  48. package/dist/commands/video.d.ts +33 -0
  49. package/dist/commands/video.js +4 -3
  50. package/dist/index.d.ts +20 -0
  51. package/dist/index.js +24 -0
  52. package/dist/lib/approvals.d.ts +29 -0
  53. package/dist/lib/approvals.js +28 -0
  54. package/dist/lib/bets.d.ts +21 -0
  55. package/dist/lib/bets.js +9 -2
  56. package/dist/lib/frontmatter.d.ts +5 -0
  57. package/dist/lib/project.d.ts +25 -0
  58. package/dist/lib/project.js +25 -7
  59. package/dist/lib/settings.d.ts +29 -0
  60. package/dist/lib/settings.js +110 -0
  61. package/dist/lib/state-files.d.ts +44 -0
  62. package/dist/lib/state-files.js +102 -0
  63. package/dist/lib/store.d.ts +44 -0
  64. package/dist/lib/store.js +97 -0
  65. package/dist/lint/constitution.d.ts +5 -0
  66. package/dist/lint/gherkin.d.ts +12 -0
  67. package/dist/lint/gherkin.js +2 -2
  68. package/dist/lint/portable.d.ts +10 -0
  69. package/dist/mcp/server.d.ts +4 -0
  70. package/dist/mcp/server.js +239 -0
  71. package/dist/planning/bets.d.ts +31 -0
  72. package/dist/planning/cli.d.ts +8 -0
  73. package/dist/planning/cli.js +17 -1
  74. package/dist/planning/delivery.d.ts +12 -0
  75. package/dist/planning/delivery.js +8 -5
  76. package/dist/planning/krs.d.ts +28 -0
  77. package/dist/planning/model.d.ts +55 -0
  78. package/dist/planning/model.js +10 -10
  79. package/dist/planning/rollout.d.ts +72 -0
  80. package/dist/planning/rollout.js +206 -0
  81. package/dist/planning/server.d.ts +113 -0
  82. package/dist/planning/server.js +9 -6
  83. package/dist/planning/ui.d.ts +7 -0
  84. package/dist/planning/ui.js +26 -1
  85. package/dist/planning/upstream.d.ts +73 -0
  86. package/dist/planning/upstream.js +78 -20
  87. package/dist/verify/detect.d.ts +3 -0
  88. package/docs/format.md +110 -1
  89. package/docs/gherkin.md +3 -0
  90. package/docs/install.md +39 -0
  91. package/docs/library.md +53 -0
  92. package/docs/planning.md +51 -5
  93. package/docs/settings.md +112 -0
  94. package/package.json +12 -2
  95. package/schemas/approvals.schema.json +27 -0
  96. package/schemas/planning/kr.schema.json +1 -1
  97. package/schemas/settings.schema.json +40 -0
  98. package/schemas/state.schema.json +6 -2
  99. package/schemas/storymap.schema.json +13 -0
  100. package/skills/nikos-mode/SKILL.md +22 -1
  101. package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
  102. package/skills/nikos-mode/playbooks/company.md +16 -0
  103. package/skills/nikos-mode/playbooks/index.md +4 -0
  104. package/skills/nikos-mode/playbooks/map-existing.md +14 -0
  105. package/skills/nikos-mode/playbooks/start.md +12 -0
  106. package/skills/nikos-mode/playbooks/tracker.md +14 -0
  107. package/skills/nikos-mode/trackers/github.md +18 -0
  108. package/skills/nikos-mode/trackers/gitlab.md +17 -0
  109. package/skills/nikos-mode/trackers/jira.md +19 -0
  110. package/skills/nikos-mode/trackers/linear.md +17 -0
  111. package/skills/verify/SKILL.md +9 -0
@@ -0,0 +1,206 @@
1
+ // `nikos planning rollout`: how the plans below this one are doing. planning/downstream.yaml
2
+ // lists the departments and teams that plan against this repo; rollout fetches a read-only copy
3
+ // of each into .nikos/downstream/<repo path>/ (gitignored), and of the ones they list in turn,
4
+ // and reports for each: has it joined, when did it last sync this plan, how is its delivery
5
+ // going, and which of its key results and bets point up here. Nothing is written to their repos.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { parse, parseDocument } from "yaml";
10
+ import { isDone } from "../lib/project.js";
11
+ import { storyMapOf } from "../commands/map.js";
12
+ import { delivery, describeDelivery } from "./delivery.js";
13
+ import { progress, isStale } from "./krs.js";
14
+ import { betIds, krIds, loadBet, loadKr, today } from "./model.js";
15
+ import { git, ignoreCache, readLock, repoEntry, upstreamEntries } from "./upstream.js";
16
+ const MAX_DEPTH = 6;
17
+ const cache = (root) => join(root, ".nikos", "downstream");
18
+ const copyOf = (root, repo) => join(cache(root), ...repo.split("/"));
19
+ const indexOf = (root) => join(cache(root), "index.json");
20
+ /** This repo's path on its host, from the origin remote: owner/repo, or group/subgroup/project. */
21
+ export function ownRepo(root) {
22
+ const url = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" }).stdout?.trim() ?? "";
23
+ const m = url.match(/^(?:[a-z+]+:\/\/[^/]+\/|[^@\s]+@[^:\s]+:)(.+?)(?:\.git)?\/?$/);
24
+ return m?.[1];
25
+ }
26
+ /** The plans a repo (or a copy of one) lists in planning/downstream.yaml. */
27
+ export function downstreamEntries(dir) {
28
+ const p = join(dir, "planning", "downstream.yaml");
29
+ if (!existsSync(p))
30
+ return [];
31
+ const list = parse(readFileSync(p, "utf8")) ?? [];
32
+ if (!Array.isArray(list))
33
+ throw new Error("planning/downstream.yaml should be a list of { name, repo }");
34
+ return list.map((e) => {
35
+ const name = typeof e === "string" ? e : String(e?.name ?? e?.repo ?? "");
36
+ if (!name)
37
+ throw new Error("planning/downstream.yaml: each entry needs a name or a repo");
38
+ return { name, ...(e?.repo ? { entry: repoEntry(e, "planning/downstream.yaml") } : {}) };
39
+ });
40
+ }
41
+ /** What a copy of a downstream plan says about itself. */
42
+ function read(root, dir, repo, self) {
43
+ const ups = (() => {
44
+ try {
45
+ return upstreamEntries(dir).map((u) => u.repo);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ })();
51
+ // Joined means it names this repo, or (when this repo's path isn't known) any plan at all.
52
+ const joined = self ? ups.includes(self) : ups.length > 0;
53
+ const lock = readLock(dir).find((l) => (self ? l.repo === self : !l.via));
54
+ const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout?.trim();
55
+ let deliv;
56
+ if (existsSync(join(dir, "intent", "storymap.yaml"))) {
57
+ try {
58
+ const map = storyMapOf(dir);
59
+ const stories = map.releases.flatMap((r) => r.stories).filter((s) => s.status !== "cancelled");
60
+ deliv = {
61
+ stories: stories.length,
62
+ done: stories.filter((s) => isDone(s.status)).length,
63
+ approved: stories.filter((s) => s.status === "approved").length,
64
+ inProgress: stories.filter((s) => s.status === "in_progress").length,
65
+ releases: map.releases.length,
66
+ };
67
+ }
68
+ catch {
69
+ // A story map that doesn't read is the team's to fix; the rest of the rollout still shows.
70
+ }
71
+ }
72
+ const krs = krIds(dir).flatMap((id) => {
73
+ try {
74
+ const k = loadKr(dir, id).data;
75
+ return [{ id, title: k.title, ...(k.aligns_to ? { aligns_to: k.aligns_to } : {}), progress: progress(k), stale: isStale(k) }];
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ });
81
+ const bets = betIds(dir).flatMap((id) => {
82
+ try {
83
+ const m = loadBet(dir, id).meta;
84
+ return [{ id, outcome: m.outcome, delivery: describeDelivery(delivery(dir, id)), krs: [m.krs?.primary, ...(m.krs?.also ?? [])].filter(Boolean) }];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ });
90
+ return {
91
+ repo,
92
+ state: joined ? "joined" : "not joined",
93
+ ...(joined ? {} : { reason: `its planning/upstream.yaml doesn't list ${self ?? "a plan"} yet` }),
94
+ ...(lock ? { synced: { on: lock.synced, commit: lock.commit, ...(head && self ? { current: lock.commit === head } : {}) } } : {}),
95
+ ...(deliv ? { delivery: deliv } : {}),
96
+ krs,
97
+ bets,
98
+ };
99
+ }
100
+ /**
101
+ * The plans below this one, at every level down. Fetches each read-only unless offline, when it
102
+ * reads the copies already on disk. Unreachable ones are reported, never fatal.
103
+ */
104
+ export function rollout(root, opts = {}) {
105
+ const self = opts.as ?? ownRepo(root);
106
+ const index = existsSync(indexOf(root)) ? JSON.parse(readFileSync(indexOf(root), "utf8")) : {};
107
+ const plans = [];
108
+ const queue = downstreamEntries(root).map((listed) => ({ listed, depth: 1, parent: self ?? "" }));
109
+ const seen = new Set();
110
+ while (queue.length) {
111
+ const { listed, depth, via, parent } = queue.shift();
112
+ const base = { name: listed.name, ...(via ? { via } : {}), krs: [], bets: [] };
113
+ if (!listed.entry) {
114
+ plans.push({ ...base, state: "no repo yet" });
115
+ continue;
116
+ }
117
+ const e = listed.entry;
118
+ if (seen.has(e.repo))
119
+ continue;
120
+ seen.add(e.repo);
121
+ const dir = copyOf(root, e.repo);
122
+ if (!opts.offline) {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ const clone = git(["clone", "--quiet", "--depth", "1", "--branch", e.ref, e.url, dir]);
125
+ if (clone.status !== 0) {
126
+ rmSync(dir, { recursive: true, force: true });
127
+ delete index[e.repo];
128
+ plans.push({ ...base, repo: e.repo, state: "unreachable", reason: (clone.stderr || "clone failed").trim().split("\n").pop() });
129
+ continue;
130
+ }
131
+ index[e.repo] = { commit: git(["rev-parse", "HEAD"], dir).stdout.trim(), fetched: today() };
132
+ rmSync(join(dir, ".git"), { recursive: true, force: true }); // a plain copy: nothing to push back
133
+ }
134
+ else if (!existsSync(dir)) {
135
+ plans.push({ ...base, repo: e.repo, state: "unreachable", reason: "not fetched yet: run nikos planning rollout" });
136
+ continue;
137
+ }
138
+ // A plan further down joins the one that listed it, not this repo.
139
+ const plan = { ...base, ...read(root, dir, e.repo, depth === 1 ? self : parent), ...(index[e.repo] ? { fetched: index[e.repo].fetched } : {}) };
140
+ if (depth > 1 && plan.synced)
141
+ delete plan.synced.current;
142
+ plans.push(plan);
143
+ if (depth >= MAX_DEPTH)
144
+ continue;
145
+ try {
146
+ for (const below of downstreamEntries(dir))
147
+ queue.push({ listed: below, depth: depth + 1, via: e.repo, parent: e.repo });
148
+ }
149
+ catch (err) {
150
+ plans.push({ name: `${e.repo}'s downstream.yaml`, state: "unreachable", reason: err.message, krs: [], bets: [] });
151
+ }
152
+ }
153
+ if (!opts.offline) {
154
+ ignoreCache(root);
155
+ mkdirSync(cache(root), { recursive: true });
156
+ writeFileSync(indexOf(root), JSON.stringify(index, null, 2));
157
+ }
158
+ return { ...(self ? { repo: self } : {}), plans };
159
+ }
160
+ /** The rollout as lines, one block per plan. */
161
+ export function formatRollout(r) {
162
+ if (!r.plans.length)
163
+ return "No plans below this one: list departments and teams in planning/downstream.yaml as `- name: Payments` and `repo: acme/payments-plan`.";
164
+ const out = [];
165
+ const joined = r.plans.filter((p) => p.state === "joined").length;
166
+ out.push(`${joined} of ${r.plans.length} plans below ${r.repo ?? "this one"} have joined.`);
167
+ for (const p of r.plans) {
168
+ const mark = p.state === "joined" ? "✓" : p.state === "not joined" ? "!" : "?";
169
+ out.push(`\n${mark} ${p.name}${p.repo && p.repo !== p.name ? ` (${p.repo})` : ""}${p.via ? `, below ${p.via}` : ""}: ${p.state}${p.reason ? `, ${p.reason}` : ""}`);
170
+ if (p.synced)
171
+ out.push(` synced on ${p.synced.on} at ${p.synced.commit.slice(0, 7)}${p.synced.current === false ? ", behind this plan: ask them to run nikos planning sync" : ""}`);
172
+ if (p.delivery)
173
+ out.push(` delivery: ${p.delivery.done} of ${p.delivery.stories} stories done${p.delivery.approved ? ` (${p.delivery.approved} approved)` : ""}, ${p.delivery.inProgress} in progress`);
174
+ for (const k of p.krs)
175
+ out.push(` KR ${k.id}: ${k.title}, ${Math.round(k.progress * 100)}%${k.stale ? ", stale" : ""}${k.aligns_to ? ` → ${k.aligns_to}` : ""}`);
176
+ for (const b of p.bets)
177
+ out.push(` bet ${b.id}: ${b.delivery}, ${b.outcome}${b.krs.length ? ` → ${b.krs.join(", ")}` : ""}`);
178
+ }
179
+ return out.join("\n");
180
+ }
181
+ /** List a department or team in planning/downstream.yaml, or give an entry already there its repo. */
182
+ export function addDownstream(root, input) {
183
+ const name = input.name?.trim();
184
+ if (!name)
185
+ throw new Error("Give the department's or team's name");
186
+ const entry = { name, ...(input.repo ? { repo: input.repo } : {}), ...(input.host ? { host: input.host } : {}) };
187
+ if (input.repo)
188
+ repoEntry(entry, "planning/downstream.yaml"); // a bad repo path or host fails before anything is written
189
+ const p = join(root, "planning", "downstream.yaml");
190
+ const doc = parseDocument(existsSync(p) ? readFileSync(p, "utf8") : "# The departments and teams that plan against this one: name, and their repo once they have one.\n[]\n");
191
+ if (!doc.contents || !("items" in doc.contents))
192
+ doc.contents = doc.createNode([]);
193
+ doc.contents.flow = false;
194
+ const items = doc.contents.items;
195
+ const at = items.findIndex((it) => {
196
+ const v = it?.toJSON?.() ?? it;
197
+ return (typeof v === "string" ? v : v?.name) === name || (input.repo && v?.repo === input.repo);
198
+ });
199
+ if (at >= 0)
200
+ items[at] = doc.createNode(entry);
201
+ else
202
+ items.push(doc.createNode(entry));
203
+ mkdirSync(join(root, "planning"), { recursive: true });
204
+ writeFileSync(p, doc.toString({ lineWidth: 0 }));
205
+ return { name, ...(input.repo ? { repo: input.repo } : {}), added: at < 0 };
206
+ }
@@ -0,0 +1,113 @@
1
+ /** What a custom view can rely on. Keep this in step with VIEW_SCRIPT and the save kinds below. */
2
+ export declare const VIEW_CONTRACT = "# Custom planning views\n\nA custom view is a page at planning/views/<name>.html (lowercase letters, digits and hyphens). It is served at /views/<name>, and the server adds /nikos-view.js and the dashboard token to it.\n\nwindow.nikos.data(): Promise<object>\n The dashboard data: { project, today, plan, krs, bets, story, upstream, views, problems }.\n krs[]: id, title, owner, unit, baseline, target, by, latest, latestValue, progress, stale, hash, checkins, upstream.\n bets[]: id, owner, team, hypothesis, krs, starts, review_on, deadline, confidence, outcome, reviews, body, hash, delivery, releases.\n upstream[] is read-only: plans synced from other repos. Never save to it.\n\nwindow.nikos.save(kind, payload): Promise<{ wrote: string }>\n \"checkin\": { id, expect, value, note?, evidence? }\n \"bet\": { id, expect, fields: { owner?, team?, hypothesis?, starts?, review_on?, deadline?, confidence?, dependencies?, outcome? } }\n \"review\": { id, expect, decision: \"continue\" | \"change\" | \"stop\" | \"complete\", reason, outcome?: \"won\" | \"lost\" }\n\nexpect is the hash of the item as data() returned it. A save rejected with status 409 means the file changed on disk: call data() again and apply the change to the new version. Other rejections carry the reason. Saves only ever write inside planning/.\n";
3
+ /** Everything the views need, as JSON. Files that fail to load are listed in `problems`, not thrown. */
4
+ export declare function planningData(root: string): {
5
+ project: any;
6
+ today: string;
7
+ plan: any;
8
+ krs: {
9
+ id: any;
10
+ title: any;
11
+ objective: any;
12
+ owner: any;
13
+ unit: any;
14
+ baseline: any;
15
+ target: any;
16
+ original_target: any;
17
+ by: any;
18
+ stale_after_days: any;
19
+ aligns_to: any;
20
+ checkins: any;
21
+ latest: any;
22
+ latestValue: any;
23
+ progress: number;
24
+ stale: boolean;
25
+ hash: string;
26
+ file: string;
27
+ upstream: {
28
+ ref: string;
29
+ repo: string;
30
+ commit: string | null;
31
+ found: boolean;
32
+ kr: any;
33
+ readOnly: boolean;
34
+ } | null;
35
+ }[];
36
+ bets: {
37
+ id: any;
38
+ owner: any;
39
+ team: any;
40
+ hypothesis: any;
41
+ krs: {
42
+ primary: any;
43
+ also: any;
44
+ };
45
+ starts: any;
46
+ review_on: any;
47
+ deadline: any;
48
+ confidence: any;
49
+ dependencies: any;
50
+ outcome: any;
51
+ reviews: any;
52
+ body: string;
53
+ hash: string;
54
+ file: string;
55
+ delivery: {
56
+ text: string;
57
+ status: "Shipped" | "Building" | "Not started";
58
+ done: number;
59
+ total: number;
60
+ stories: {
61
+ id: string;
62
+ status: string;
63
+ }[];
64
+ releases: number[];
65
+ };
66
+ releases: {
67
+ number: number;
68
+ title: string;
69
+ href: string;
70
+ }[];
71
+ }[];
72
+ story: {
73
+ activities: {
74
+ id: string;
75
+ title: string;
76
+ steps: {
77
+ id: string;
78
+ title: string;
79
+ }[];
80
+ }[];
81
+ releases: import("../commands/map.js").MapRelease[];
82
+ unreleased: import("../commands/map.js").MapStory[];
83
+ };
84
+ upstream: {
85
+ repo: string;
86
+ commit?: string;
87
+ krs: any[];
88
+ bets: any[];
89
+ }[];
90
+ downstream: import("./rollout.js").Rollout | null;
91
+ views: string[];
92
+ problems: string[];
93
+ };
94
+ /** Write the whole dashboard as one offline HTML file. Warns first when other repos' plans are inside. */
95
+ export declare function exportSnapshot(root: string, file: string): {
96
+ wrote: string;
97
+ warned: boolean;
98
+ };
99
+ export interface RunningServer {
100
+ url: string;
101
+ token: string;
102
+ port: number;
103
+ close(): Promise<void>;
104
+ }
105
+ /** Start the dashboard on 127.0.0.1 with a fresh token. The url carries the token for the browser. */
106
+ export declare function startServer(root: string, opts?: {
107
+ port?: number;
108
+ }): Promise<RunningServer>;
109
+ /** `nikos planning serve`: run the dashboard until Ctrl+C, or export it to one file. */
110
+ export declare function runServe(root: string, opts?: {
111
+ port?: number;
112
+ exportTo?: string;
113
+ }): Promise<number>;
@@ -12,6 +12,7 @@ import { editBet, reviewBet } from "./bets.js";
12
12
  import { delivery, describeDelivery } from "./delivery.js";
13
13
  import { checkIn, isStale, latest, progress } from "./krs.js";
14
14
  import { betIds, krIds, loadBet, loadKr, loadPlan, paths, StaleError, today } from "./model.js";
15
+ import { rollout } from "./rollout.js";
15
16
  import { loadUpstream } from "./upstream.js";
16
17
  import { renderDashboard } from "./ui.js";
17
18
  const MAX_BODY = 100_000;
@@ -170,6 +171,8 @@ export function planningData(root) {
170
171
  bets,
171
172
  story: { activities: story.activities, releases: story.releases, unreleased: story.unreleased },
172
173
  upstream,
174
+ // The plans below this one, from the copies the last rollout fetched: loading never fetches.
175
+ downstream: existsSync(join(root, "planning", "downstream.yaml")) ? rollout(root, { offline: true }) : null,
173
176
  views,
174
177
  problems,
175
178
  };
@@ -177,13 +180,13 @@ export function planningData(root) {
177
180
  /** Write the whole dashboard as one offline HTML file. Warns first when other repos' plans are inside. */
178
181
  export function exportSnapshot(root, file) {
179
182
  const out = resolve(file);
180
- const upstream = loadUpstream(root);
181
- const warned = upstream.length > 0;
183
+ const data = planningData(root);
184
+ const names = [...loadUpstream(root).map((u) => u.repo), ...(data.downstream?.plans ?? []).filter((p) => p.repo && p.state !== "unreachable").map((p) => p.repo)];
185
+ const warned = names.length > 0;
182
186
  if (warned) {
183
- const names = upstream.map((u) => u.repo).join(", ");
184
- process.stderr.write(`! This snapshot includes plans from other repos (${names}); share it only with people allowed to see them.\n`);
187
+ process.stderr.write(`! This snapshot includes plans from other repos (${names.join(", ")}); share it only with people allowed to see them.\n`);
185
188
  }
186
- writeFileSync(out, renderDashboard({ data: planningData(root), readOnly: true }));
189
+ writeFileSync(out, renderDashboard({ data, readOnly: true }));
187
190
  return { wrote: out, warned };
188
191
  }
189
192
  /** Start the dashboard on 127.0.0.1 with a fresh token. The url carries the token for the browser. */
@@ -253,7 +256,7 @@ function serveGet(root, token, path, res) {
253
256
  if (path === "/api/data")
254
257
  return send(res, 200, planningData(root));
255
258
  if (path === "/map")
256
- return send(res, 200, renderMap(root), HTML);
259
+ return send(res, 200, renderMap(root, { live: true }), HTML);
257
260
  if (path === "/nikos-view.js")
258
261
  return send(res, 200, VIEW_SCRIPT, JS);
259
262
  const m = /^\/views\/([^/]+)$/.exec(path);
@@ -0,0 +1,7 @@
1
+ export interface DashboardOptions {
2
+ data: any;
3
+ token?: string;
4
+ readOnly: boolean;
5
+ }
6
+ /** The whole dashboard as one HTML page. Read-only renders carry no save code and no token. */
7
+ export declare function renderDashboard({ data, token, readOnly }: DashboardOptions): string;
@@ -129,6 +129,7 @@ function render() {
129
129
  else if (p[0] === 'bets') html = betsView();
130
130
  else if (p[0] === 'timeline') html = timelineView();
131
131
  else if (p[0] === 'guide') html = guideView();
132
+ else if (p[0] === 'rollout') html = rolloutView();
132
133
  else html = krsView();
133
134
  main.innerHTML = (D.problems && D.problems.length ? '<div class="notice">Some planning files could not be read: ' + esc(D.problems.join('; ')) + '</div>' : '') + html;
134
135
  }
@@ -176,6 +177,30 @@ function guideView() {
176
177
  '<h2 style="margin:20px 0 10px">What nikOS adds</h2><div class="cards">' + value + '</div>';
177
178
  }
178
179
 
180
+ function rolloutView() {
181
+ var r = D.downstream;
182
+ if (!r || !r.plans.length) return '<h1>Rollout</h1><p class="muted">No plans below this one yet. List departments and teams in planning/downstream.yaml.</p>';
183
+ var joined = r.plans.filter(function (p) { return p.state === 'joined'; }).length;
184
+ var cls = { 'joined': 'green', 'not joined': 'amber', 'unreachable': '', 'no repo yet': '' };
185
+ var cards = r.plans.map(function (p) {
186
+ var rows = [];
187
+ if (p.synced) rows.push('Synced ' + esc(p.synced.on) + (p.synced.current === false ? ', behind this plan' : ''));
188
+ else if (p.state === 'joined') rows.push('Not synced yet');
189
+ if (p.delivery) {
190
+ var pct = p.delivery.stories ? Math.round(p.delivery.done / p.delivery.stories * 100) : 0;
191
+ rows.push(esc(p.delivery.done) + ' of ' + esc(p.delivery.stories) + ' stories done' + (p.delivery.approved ? ', ' + esc(p.delivery.approved) + ' approved' : '') + '<div class="progress"><div style="width:' + pct + '%"></div></div>');
192
+ }
193
+ var refs = p.krs.map(function (k) { return '<li>KR ' + esc(k.title) + ', ' + Math.round(k.progress * 100) + '%' + (k.stale ? ' ' + chip('Stale', 'amber') : '') + (k.aligns_to ? ' <span class="muted">→ ' + esc(k.aligns_to) + '</span>' : '') + '</li>'; })
194
+ .concat(p.bets.map(function (b) { return '<li>Bet ' + esc(b.id) + ', ' + esc(b.delivery) + (b.krs.length ? ' <span class="muted">→ ' + esc(b.krs.join(', ')) + '</span>' : '') + '</li>'; }));
195
+ return '<div class="card"><div class="row"><strong>' + esc(p.name) + '</strong>' + chip(p.state, cls[p.state] || '') + '</div>' +
196
+ '<div class="muted">' + esc(p.repo || 'no repo yet') + (p.via ? ' · below ' + esc(p.via) : '') + (p.fetched ? ' · read ' + esc(p.fetched) : '') + '</div>' +
197
+ (p.reason ? '<div class="muted" style="margin-top:6px">' + esc(p.reason) + '</div>' : '') +
198
+ rows.map(function (x) { return '<div style="margin-top:8px">' + x + '</div>'; }).join('') +
199
+ (refs.length ? '<ul class="checkins" style="margin-top:8px">' + refs.join('') + '</ul>' : '') + '</div>';
200
+ }).join('');
201
+ return '<h1>Rollout</h1><p class="muted">' + joined + ' of ' + r.plans.length + ' plans below this one have joined. Read from copies fetched by nikos planning rollout.</p><div class="cards">' + cards + '</div>';
202
+ }
203
+
179
204
  function krCard(k) {
180
205
  var chips = '';
181
206
  if (k.stale) chips += chip('Stale', 'amber');
@@ -403,7 +428,7 @@ main.addEventListener('click', function (e) {
403
428
  export function renderDashboard({ data, token, readOnly }) {
404
429
  const live = !readOnly;
405
430
  const json = JSON.stringify(data).replace(/[<\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
406
- const nav = `<a data-nav="krs" href="#/krs">Key results</a><a data-nav="bets" href="#/bets">Bets</a><a data-nav="timeline" href="#/timeline">Timeline</a><a data-nav="guide" href="#/guide">How it works</a>`;
431
+ const nav = `<a data-nav="krs" href="#/krs">Key results</a><a data-nav="bets" href="#/bets">Bets</a><a data-nav="timeline" href="#/timeline">Timeline</a>${data.downstream ? `<a data-nav="rollout" href="#/rollout">Rollout</a>` : ""}<a data-nav="guide" href="#/guide">How it works</a>`;
407
432
  // The link out appears only in the served dashboard: an export is a closed file with no outside links.
408
433
  const links = live
409
434
  ? `<div class="links"><a href="https://www.nikovijay.com/connect?from=nikos-planning" target="_blank" rel="noopener">Made by Niko Vijay</a></div>\n`
@@ -0,0 +1,73 @@
1
+ export interface UpstreamEntry {
2
+ /** The repo's path on its host: owner/repo, or group/subgroup/project. */
3
+ repo: string;
4
+ ref: string;
5
+ url: string;
6
+ }
7
+ export interface LockEntry {
8
+ repo: string;
9
+ ref: string;
10
+ commit: string;
11
+ synced: string;
12
+ /** For a plan further up the chain: the upstream that named it. */
13
+ via?: string;
14
+ }
15
+ export declare const git: (args: string[], cwd?: string) => import("child_process").SpawnSyncReturns<string>;
16
+ /**
17
+ * The upstream plans listed in planning/upstream.yaml. A repo is its path on the host; the url is
18
+ * https://<host>/<repo>.git, with host github.com unless the entry names one (gitlab.com, or a
19
+ * self-managed host), and url: overrides it for anything else.
20
+ */
21
+ export declare function upstreamEntries(root: string): UpstreamEntry[];
22
+ /** Check one { repo, ref, host, url } entry from a plan's yaml list, and work out its url. */
23
+ export declare function repoEntry(e: any, file: string): UpstreamEntry;
24
+ export declare function readLock(root: string): LockEntry[];
25
+ /** Make sure .nikos/ is gitignored, so upstream copies are never committed. */
26
+ export declare function ignoreCache(root: string): void;
27
+ export interface SyncReport {
28
+ synced: LockEntry[];
29
+ unresolved: {
30
+ repo: string;
31
+ reason: string;
32
+ }[];
33
+ }
34
+ /**
35
+ * Fetch every upstream plan read-only, and the plans above those: a team that names its
36
+ * department also gets the department's company, so settings and plans reach every level.
37
+ * Unreachable ones are reported, never fatal.
38
+ */
39
+ export declare function sync(root: string): SyncReport;
40
+ /** Compare each locked commit with the upstream's current one. */
41
+ export declare function checkStale(root: string): {
42
+ stale: {
43
+ repo: string;
44
+ locked: string;
45
+ current: string;
46
+ }[];
47
+ unresolved: {
48
+ repo: string;
49
+ reason: string;
50
+ }[];
51
+ };
52
+ /** The read-only copies on disk: each upstream's key results and bets. */
53
+ export declare function loadUpstream(root: string): {
54
+ repo: string;
55
+ commit?: string;
56
+ krs: any[];
57
+ bets: any[];
58
+ }[];
59
+ /** Find owner/repo#id among the synced copies. */
60
+ export declare function resolveRef(root: string, ref: string): {
61
+ repo: string;
62
+ kind: "kr" | "bet";
63
+ data: any;
64
+ } | undefined;
65
+ /** Every owner/repo#id this repo's planning files and story map name, with whether it resolves. */
66
+ export declare function crossRefs(root: string): {
67
+ ref: string;
68
+ file: string;
69
+ resolved: boolean;
70
+ }[];
71
+ export declare function runSync(root: string, opts?: {
72
+ check?: boolean;
73
+ }): Promise<number>;
@@ -1,17 +1,24 @@
1
1
  // Plans this repo works toward, in other repos. planning/upstream.yaml lists them;
2
- // `nikos planning sync` fetches a read-only copy of each into .nikos/upstream/<owner>/<repo>/
2
+ // `nikos planning sync` fetches a read-only copy of each into .nikos/upstream/<repo path>/
3
3
  // (gitignored) and records the commit it read in planning/upstream.lock. This repo only ever
4
- // stores references like owner/repo#id, never the upstream content.
4
+ // stores references like owner/repo#id, never the upstream content. Any git host works:
5
+ // GitHub by default, GitLab (with nested groups) or a self-managed host through `host:`, and
6
+ // anything else through `url:`.
5
7
  import { spawnSync } from "node:child_process";
6
- import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
8
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
7
9
  import { basename, join } from "node:path";
8
10
  import { parse, stringify } from "yaml";
9
- import { CROSS_REF } from "../lib/bets.js";
11
+ import { CROSS_REF, REPO_PATH } from "../lib/bets.js";
10
12
  import { parseFrontMatter } from "../lib/frontmatter.js";
11
13
  import { walk } from "../lib/project.js";
12
14
  import { betIds, krIds, loadBet, loadKr, paths, today, writePlanning } from "./model.js";
13
- const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
14
- /** The upstream plans listed in planning/upstream.yaml. A repo is owner/repo; url defaults to GitHub. */
15
+ export const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
16
+ const HOST = /^[A-Za-z0-9.-]+(:[0-9]+)?$/;
17
+ /**
18
+ * The upstream plans listed in planning/upstream.yaml. A repo is its path on the host; the url is
19
+ * https://<host>/<repo>.git, with host github.com unless the entry names one (gitlab.com, or a
20
+ * self-managed host), and url: overrides it for anything else.
21
+ */
15
22
  export function upstreamEntries(root) {
16
23
  const p = paths(root).upstream;
17
24
  if (!existsSync(p))
@@ -19,11 +26,16 @@ export function upstreamEntries(root) {
19
26
  const list = parse(readFileSync(p, "utf8")) ?? [];
20
27
  if (!Array.isArray(list))
21
28
  throw new Error("planning/upstream.yaml should be a list of { repo, ref }");
22
- return list.map((e) => {
23
- if (typeof e?.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(e.repo))
24
- throw new Error(`planning/upstream.yaml: '${e?.repo}' is not owner/repo`);
25
- return { repo: e.repo, ref: e.ref ?? "main", url: e.url ?? `https://github.com/${e.repo}.git` };
26
- });
29
+ return list.map((e) => repoEntry(e, "planning/upstream.yaml"));
30
+ }
31
+ /** Check one { repo, ref, host, url } entry from a plan's yaml list, and work out its url. */
32
+ export function repoEntry(e, file) {
33
+ if (typeof e?.repo !== "string" || !REPO_PATH.test(e.repo)) {
34
+ throw new Error(`${file}: '${e?.repo}' is not a repo path (owner/repo, or group/subgroup/project on GitLab)`);
35
+ }
36
+ if (e.host !== undefined && (typeof e.host !== "string" || !HOST.test(e.host)))
37
+ throw new Error(`${file}: '${e.host}' is not a host name, e.g. gitlab.com`);
38
+ return { repo: e.repo, ref: e.ref ?? "main", url: e.url ?? `https://${e.host ?? "github.com"}/${e.repo}.git` };
27
39
  }
28
40
  export function readLock(root) {
29
41
  const p = paths(root).lock;
@@ -31,18 +43,27 @@ export function readLock(root) {
31
43
  }
32
44
  const cacheDir = (root, repo) => join(paths(root).cache, ...repo.split("/"));
33
45
  /** Make sure .nikos/ is gitignored, so upstream copies are never committed. */
34
- function ignoreCache(root) {
46
+ export function ignoreCache(root) {
35
47
  const p = join(root, ".gitignore");
36
48
  const text = existsSync(p) ? readFileSync(p, "utf8") : "";
37
49
  if (!/^\/?\.nikos\/?$/m.test(text))
38
50
  appendFileSync(p, `${text && !text.endsWith("\n") ? "\n" : ""}# nikos planning: read-only copies of upstream plans\n.nikos/\n`);
39
51
  }
40
- /** Fetch every upstream plan read-only. Unreachable ones are reported, never fatal. */
52
+ /** How many levels up sync follows: team → group → department → company, with room to spare. */
53
+ const MAX_DEPTH = 6;
54
+ /**
55
+ * Fetch every upstream plan read-only, and the plans above those: a team that names its
56
+ * department also gets the department's company, so settings and plans reach every level.
57
+ * Unreachable ones are reported, never fatal.
58
+ */
41
59
  export function sync(root) {
42
60
  ignoreCache(root);
43
61
  const report = { synced: [], unresolved: [] };
44
62
  const previous = readLock(root);
45
- for (const e of upstreamEntries(root)) {
63
+ const queue = upstreamEntries(root).map((entry) => ({ entry, depth: 1 }));
64
+ const seen = new Set(queue.map((q) => q.entry.repo));
65
+ while (queue.length) {
66
+ const { entry: e, depth, via } = queue.shift();
46
67
  const dir = cacheDir(root, e.repo);
47
68
  rmSync(dir, { recursive: true, force: true });
48
69
  const clone = git(["clone", "--quiet", "--depth", "1", "--branch", e.ref, e.url, dir]);
@@ -56,14 +77,42 @@ export function sync(root) {
56
77
  }
57
78
  const commit = git(["rev-parse", "HEAD"], dir).stdout.trim();
58
79
  rmSync(join(dir, ".git"), { recursive: true, force: true }); // a plain copy: nothing to push back
59
- report.synced.push({ repo: e.repo, ref: e.ref, commit, synced: today() });
80
+ report.synced.push({ repo: e.repo, ref: e.ref, commit, synced: today(), ...(via ? { via } : {}) });
81
+ if (depth >= MAX_DEPTH)
82
+ continue;
83
+ let above = [];
84
+ try {
85
+ above = upstreamEntries(dir);
86
+ }
87
+ catch (err) {
88
+ report.unresolved.push({ repo: `${e.repo}'s upstream.yaml`, reason: err.message });
89
+ }
90
+ for (const up of above) {
91
+ if (seen.has(up.repo))
92
+ continue;
93
+ seen.add(up.repo);
94
+ queue.push({ entry: up, depth: depth + 1, via: e.repo });
95
+ }
60
96
  }
61
97
  writePlanning(root, paths(root).lock, `# Written by nikos planning sync: the upstream commit each plan was read at.\n${stringify(report.synced)}`);
62
98
  return report;
63
99
  }
64
100
  /** Compare each locked commit with the upstream's current one. */
65
101
  export function checkStale(root) {
66
- const entries = upstreamEntries(root);
102
+ // This repo's own upstreams, and the ones further up that their copies name.
103
+ const entries = [...upstreamEntries(root)];
104
+ for (const l of readLock(root)) {
105
+ if (!REPO_PATH.test(l.repo))
106
+ continue;
107
+ try {
108
+ for (const e of upstreamEntries(cacheDir(root, l.repo)))
109
+ if (!entries.some((x) => x.repo === e.repo))
110
+ entries.push(e);
111
+ }
112
+ catch {
113
+ // A copy with a broken upstream.yaml was reported at sync.
114
+ }
115
+ }
67
116
  const out = { stale: [], unresolved: [] };
68
117
  for (const l of readLock(root)) {
69
118
  const e = entries.find((x) => x.repo === l.repo);
@@ -78,13 +127,22 @@ export function checkStale(root) {
78
127
  }
79
128
  return out;
80
129
  }
130
+ /** The repo paths under .nikos/upstream/ that hold a planning/ folder, at any group depth. */
131
+ function copiesOnDisk(cache, prefix = "", depth = 0) {
132
+ if (depth > 8 || !existsSync(cache))
133
+ return [];
134
+ return readdirSync(cache, { withFileTypes: true })
135
+ .filter((d) => d.isDirectory() && d.name !== "planning")
136
+ .flatMap((d) => {
137
+ const path = prefix ? `${prefix}/${d.name}` : d.name;
138
+ return existsSync(join(cache, d.name, "planning")) ? [path] : copiesOnDisk(join(cache, d.name), path, depth + 1);
139
+ });
140
+ }
81
141
  /** The read-only copies on disk: each upstream's key results and bets. */
82
142
  export function loadUpstream(root) {
83
143
  const lock = readLock(root);
84
- const repos = new Set(lock.map((l) => l.repo));
85
- // Also pick up copies whose lock entry is missing (e.g. placed by hand).
86
- for (const owner of walk(paths(root).cache).map((p) => p.slice(paths(root).cache.length + 1).split(/[\\/]/).slice(0, 2).join("/")))
87
- repos.add(owner);
144
+ // Locked repos, plus copies whose lock entry is missing (e.g. placed by hand).
145
+ const repos = new Set([...lock.map((l) => l.repo), ...copiesOnDisk(paths(root).cache)].filter((r) => REPO_PATH.test(r)));
88
146
  const out = [];
89
147
  for (const repo of repos) {
90
148
  const root2 = cacheDir(root, repo);
@@ -0,0 +1,3 @@
1
+ export type Stack = "js" | "ts" | "python";
2
+ /** Pick the Cucumber runner's stack from the files at the repo root. Undefined means ask the person. */
3
+ export declare function detectStack(root: string): Stack | undefined;