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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Key results in planning/krs/<id>.yaml: a baseline, a target and a date, and dated
|
|
2
|
+
// check-ins that are only ever added to. Revising the target keeps the original.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { BET_ID } from "../lib/bets.js";
|
|
5
|
+
import { DATE, checkKr, describeErrors, krText, loadKr, paths, today, writePlanning } from "./model.js";
|
|
6
|
+
function checked(data) {
|
|
7
|
+
if (!checkKr(data))
|
|
8
|
+
throw new Error(`Not a valid key result: ${describeErrors(checkKr.errors).join("; ")}`);
|
|
9
|
+
return data;
|
|
10
|
+
}
|
|
11
|
+
export function newKr(input, root) {
|
|
12
|
+
if (!BET_ID.test(input.id))
|
|
13
|
+
throw new Error(`'${input.id}' is not a key result id (lowercase words joined by hyphens)`);
|
|
14
|
+
if (!input.title?.trim())
|
|
15
|
+
throw new Error("A key result needs a title (--title)");
|
|
16
|
+
if (!input.owner?.trim())
|
|
17
|
+
throw new Error("A key result needs an owner (--owner)");
|
|
18
|
+
if (!Number.isFinite(input.baseline) || !Number.isFinite(input.target))
|
|
19
|
+
throw new Error("A key result needs a numeric --baseline and --target");
|
|
20
|
+
if (!DATE.test(input.by ?? ""))
|
|
21
|
+
throw new Error("A key result needs a date (--by YYYY-MM-DD)");
|
|
22
|
+
const data = checked({
|
|
23
|
+
id: input.id,
|
|
24
|
+
title: input.title.trim(),
|
|
25
|
+
...(input.objective ? { objective: input.objective } : {}),
|
|
26
|
+
owner: input.owner.trim(),
|
|
27
|
+
...(input.unit !== undefined ? { unit: input.unit } : {}),
|
|
28
|
+
baseline: input.baseline,
|
|
29
|
+
target: input.target,
|
|
30
|
+
original_target: input.target,
|
|
31
|
+
by: input.by,
|
|
32
|
+
...(input.alignsTo ? { aligns_to: input.alignsTo } : {}),
|
|
33
|
+
...(input.staleAfterDays ? { stale_after_days: input.staleAfterDays } : {}),
|
|
34
|
+
checkins: [],
|
|
35
|
+
});
|
|
36
|
+
return writePlanning(root, join(paths(root).krs, `${input.id}.yaml`), krText(data), { create: true });
|
|
37
|
+
}
|
|
38
|
+
/** Add a dated check-in. Earlier check-ins are never changed. */
|
|
39
|
+
export function checkIn(root, id, c) {
|
|
40
|
+
if (!Number.isFinite(c.value))
|
|
41
|
+
throw new Error("A check-in needs a numeric value");
|
|
42
|
+
const kr = loadKr(root, id);
|
|
43
|
+
const data = structuredClone(kr.data);
|
|
44
|
+
data.checkins = [
|
|
45
|
+
...(data.checkins ?? []),
|
|
46
|
+
{ date: c.date ?? today(), value: c.value, ...(c.note?.trim() ? { note: c.note.trim() } : {}), ...(c.evidence?.trim() ? { evidence: c.evidence.trim() } : {}) },
|
|
47
|
+
];
|
|
48
|
+
return writePlanning(root, join(paths(root).krs, `${id}.yaml`), krText(checked(data)), { expect: c.expect ?? kr.hash });
|
|
49
|
+
}
|
|
50
|
+
/** Move the target. The original target stays recorded. */
|
|
51
|
+
export function reviseTarget(root, id, target, expect) {
|
|
52
|
+
if (!Number.isFinite(target))
|
|
53
|
+
throw new Error("A target is a number");
|
|
54
|
+
const kr = loadKr(root, id);
|
|
55
|
+
const data = structuredClone(kr.data);
|
|
56
|
+
data.original_target ??= data.target;
|
|
57
|
+
data.target = target;
|
|
58
|
+
return writePlanning(root, join(paths(root).krs, `${id}.yaml`), krText(checked(data)), { expect: expect ?? kr.hash });
|
|
59
|
+
}
|
|
60
|
+
export const latest = (kr) => (kr.checkins?.length ? kr.checkins[kr.checkins.length - 1] : undefined);
|
|
61
|
+
const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
|
|
62
|
+
/** Stale when the last check-in (or, with none, nothing at all) is older than stale_after_days (default 14). */
|
|
63
|
+
export function isStale(kr, on = today()) {
|
|
64
|
+
const last = latest(kr);
|
|
65
|
+
if (!last)
|
|
66
|
+
return true;
|
|
67
|
+
return days(last.date, on) > (kr.stale_after_days ?? 14);
|
|
68
|
+
}
|
|
69
|
+
/** Progress from baseline toward target, 0..1 (can pass 1). Works for targets below the baseline too. */
|
|
70
|
+
export function progress(kr) {
|
|
71
|
+
const v = latest(kr)?.value ?? kr.baseline;
|
|
72
|
+
return kr.target === kr.baseline ? 1 : (v - kr.baseline) / (kr.target - kr.baseline);
|
|
73
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The planning/ folder: bet cards, key results and the planning context. Every write in
|
|
2
|
+
// the pack (command line and dashboard) goes through here, so both get the same checks.
|
|
3
|
+
import Ajv2020Module from "ajv/dist/2020.js";
|
|
4
|
+
const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { Document, parse } from "yaml";
|
|
10
|
+
import { parseFrontMatter } from "../lib/frontmatter.js";
|
|
11
|
+
import { walk } from "../lib/project.js";
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
// Works from src/ (tests) and dist/ (published): schemas sit at the package root.
|
|
14
|
+
const schemaDir = [join(here, "../../schemas/planning"), join(here, "../schemas/planning")].find((d) => existsSync(d));
|
|
15
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
16
|
+
const schema = (name) => ajv.compile(JSON.parse(readFileSync(join(schemaDir, name), "utf8")));
|
|
17
|
+
export const checkBet = schema("bet.schema.json");
|
|
18
|
+
export const checkKr = schema("kr.schema.json");
|
|
19
|
+
export const checkPlan = schema("plan.schema.json");
|
|
20
|
+
export const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
export const today = () => new Date().toISOString().slice(0, 10);
|
|
22
|
+
/** The repo root holding planning/ (and usually intent/): a repo root, or planning/ itself. */
|
|
23
|
+
export function findRoot(start) {
|
|
24
|
+
const abs = resolve(start);
|
|
25
|
+
return basename(abs) === "planning" && !existsSync(join(abs, "planning")) ? dirname(abs) : abs;
|
|
26
|
+
}
|
|
27
|
+
export const paths = (root) => ({
|
|
28
|
+
planning: join(root, "planning"),
|
|
29
|
+
bets: join(root, "planning", "bets"),
|
|
30
|
+
krs: join(root, "planning", "krs"),
|
|
31
|
+
plan: join(root, "planning", "plan.yaml"),
|
|
32
|
+
views: join(root, "planning", "views"),
|
|
33
|
+
upstream: join(root, "planning", "upstream.yaml"),
|
|
34
|
+
lock: join(root, "planning", "upstream.lock"),
|
|
35
|
+
cache: join(root, ".nikos", "upstream"),
|
|
36
|
+
intent: join(root, "intent"),
|
|
37
|
+
});
|
|
38
|
+
export const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
39
|
+
export function describeErrors(errors) {
|
|
40
|
+
return (errors ?? []).map((e) => {
|
|
41
|
+
const at = e.instancePath.replace(/^\//, "").split("/").join(".") || "(root)";
|
|
42
|
+
if (e.keyword === "additionalProperties")
|
|
43
|
+
return `${at === "(root)" ? "" : at + ": "}unknown field '${e.params.additionalProperty}'`;
|
|
44
|
+
if (e.keyword === "required")
|
|
45
|
+
return `${at === "(root)" ? "" : at + ": "}missing required field '${e.params.missingProperty}'`;
|
|
46
|
+
if (e.keyword === "enum")
|
|
47
|
+
return `${at} must be one of ${e.params.allowedValues.join(", ")}`;
|
|
48
|
+
return `${at} ${e.message}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** A file inside planning/, refusing anything that would land outside it. */
|
|
52
|
+
export function inside(root, ...parts) {
|
|
53
|
+
const base = paths(root).planning;
|
|
54
|
+
const path = resolve(base, ...parts);
|
|
55
|
+
if (path !== base && !path.startsWith(base + sep))
|
|
56
|
+
throw new Error(`Refusing to write outside planning/: ${parts.join("/")}`);
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
export class StaleError extends Error {
|
|
60
|
+
constructor(file) {
|
|
61
|
+
super(`${file} changed on disk since it was loaded; reload and try again`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Write a planning file. With `expect`, refuse if the file on disk isn't the version that was loaded. */
|
|
65
|
+
export function writePlanning(root, path, text, opts = {}) {
|
|
66
|
+
const rel = relative(root, path);
|
|
67
|
+
if (!path.startsWith(paths(root).planning + sep))
|
|
68
|
+
throw new Error(`Refusing to write outside planning/: ${rel}`);
|
|
69
|
+
if (opts.expect !== undefined) {
|
|
70
|
+
const current = existsSync(path) ? hash(readFileSync(path, "utf8")) : "";
|
|
71
|
+
if (current !== opts.expect)
|
|
72
|
+
throw new StaleError(rel);
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(path, text, opts.create ? { flag: "wx" } : undefined);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code === "EEXIST")
|
|
80
|
+
throw new Error(`${rel} already exists`);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return rel;
|
|
84
|
+
}
|
|
85
|
+
const yamlText = (data, flow = []) => {
|
|
86
|
+
const doc = new Document(data);
|
|
87
|
+
for (const key of flow) {
|
|
88
|
+
const node = doc.getIn(key.split("."), true);
|
|
89
|
+
if (node && typeof node === "object" && "flow" in node)
|
|
90
|
+
node.flow = true;
|
|
91
|
+
}
|
|
92
|
+
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
93
|
+
};
|
|
94
|
+
export const betText = (meta, body) => `---\n${yamlText(meta, ["krs.also", "dependencies"])}---\n${body}`;
|
|
95
|
+
export const krText = (data) => yamlText(data);
|
|
96
|
+
export function loadBet(root, id) {
|
|
97
|
+
const path = join(paths(root).bets, `${id}.md`);
|
|
98
|
+
if (!existsSync(path))
|
|
99
|
+
throw new Error(`No bet called '${id}' (planning/bets/${id}.md)`);
|
|
100
|
+
const text = readFileSync(path, "utf8");
|
|
101
|
+
const fm = parseFrontMatter(text);
|
|
102
|
+
if (!fm)
|
|
103
|
+
throw new Error(`planning/bets/${id}.md has no front matter`);
|
|
104
|
+
return { meta: fm.meta, body: fm.body, file: relative(root, path), hash: hash(text) };
|
|
105
|
+
}
|
|
106
|
+
export function loadKr(root, id) {
|
|
107
|
+
const path = join(paths(root).krs, `${id}.yaml`);
|
|
108
|
+
if (!existsSync(path))
|
|
109
|
+
throw new Error(`No key result called '${id}' (planning/krs/${id}.yaml)`);
|
|
110
|
+
const text = readFileSync(path, "utf8");
|
|
111
|
+
return { data: parse(text), file: relative(root, path), hash: hash(text) };
|
|
112
|
+
}
|
|
113
|
+
export const betIds = (root) => walk(paths(root).bets, ".md").map((p) => basename(p, ".md")).sort();
|
|
114
|
+
export const krIds = (root) => walk(paths(root).krs, ".yaml").map((p) => basename(p, ".yaml")).sort();
|
|
115
|
+
export function loadPlan(root) {
|
|
116
|
+
const p = paths(root).plan;
|
|
117
|
+
return existsSync(p) ? parse(readFileSync(p, "utf8")) : undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Check every planning file against its schema and the links between them. */
|
|
120
|
+
export function validatePlanning(root) {
|
|
121
|
+
const issues = [];
|
|
122
|
+
const krs = new Set(krIds(root));
|
|
123
|
+
const plan = loadPlan(root);
|
|
124
|
+
if (plan !== undefined && !checkPlan(plan))
|
|
125
|
+
for (const m of describeErrors(checkPlan.errors))
|
|
126
|
+
issues.push({ file: "planning/plan.yaml", message: m });
|
|
127
|
+
for (const id of krIds(root)) {
|
|
128
|
+
let kr;
|
|
129
|
+
try {
|
|
130
|
+
kr = loadKr(root, id);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
issues.push({ file: `planning/krs/${id}.yaml`, message: err.message.split("\n")[0] });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!checkKr(kr.data))
|
|
137
|
+
for (const m of describeErrors(checkKr.errors))
|
|
138
|
+
issues.push({ file: kr.file, message: m });
|
|
139
|
+
else if (kr.data.id !== id)
|
|
140
|
+
issues.push({ file: kr.file, message: `id '${kr.data.id}' doesn't match the file name` });
|
|
141
|
+
}
|
|
142
|
+
for (const id of betIds(root)) {
|
|
143
|
+
let bet;
|
|
144
|
+
try {
|
|
145
|
+
bet = loadBet(root, id);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
issues.push({ file: `planning/bets/${id}.md`, message: err.message.split("\n")[0] });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!checkBet(bet.meta)) {
|
|
152
|
+
for (const m of describeErrors(checkBet.errors))
|
|
153
|
+
issues.push({ file: bet.file, message: m });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (bet.meta.id !== id)
|
|
157
|
+
issues.push({ file: bet.file, message: `id '${bet.meta.id}' doesn't match the file name` });
|
|
158
|
+
for (const ref of [bet.meta.krs.primary, ...(bet.meta.krs.also ?? [])].filter(Boolean)) {
|
|
159
|
+
if (!ref.includes("#") && !krs.has(ref))
|
|
160
|
+
issues.push({ file: bet.file, message: `links key result '${ref}', which has no planning/krs/${ref}.yaml` });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return issues;
|
|
164
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// The local planning dashboard: a server on 127.0.0.1 that shows the planning/ folder and
|
|
2
|
+
// saves through the same model functions as the command line. Every save needs the token
|
|
3
|
+
// printed at start; the static pages and the export need none. Also the one-file export and
|
|
4
|
+
// the contract custom views are written against.
|
|
5
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
9
|
+
import { renderMap, storyMapOf } from "../commands/map.js";
|
|
10
|
+
import { BET_ID } from "../lib/bets.js";
|
|
11
|
+
import { editBet, reviewBet } from "./bets.js";
|
|
12
|
+
import { delivery, describeDelivery } from "./delivery.js";
|
|
13
|
+
import { checkIn, isStale, latest, progress } from "./krs.js";
|
|
14
|
+
import { betIds, krIds, loadBet, loadKr, loadPlan, paths, StaleError, today } from "./model.js";
|
|
15
|
+
import { loadUpstream } from "./upstream.js";
|
|
16
|
+
import { renderDashboard } from "./ui.js";
|
|
17
|
+
const MAX_BODY = 100_000;
|
|
18
|
+
const VIEW_NAME = /^[a-z0-9-]+$/;
|
|
19
|
+
const VIEW_FILE = /^[a-z0-9-]+\.html$/;
|
|
20
|
+
const HTML = "text/html; charset=utf-8";
|
|
21
|
+
const JS = "application/javascript; charset=utf-8";
|
|
22
|
+
/** What a custom view can rely on. Keep this in step with VIEW_SCRIPT and the save kinds below. */
|
|
23
|
+
export const VIEW_CONTRACT = `# Custom planning views
|
|
24
|
+
|
|
25
|
+
A 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.
|
|
26
|
+
|
|
27
|
+
window.nikos.data(): Promise<object>
|
|
28
|
+
The dashboard data: { project, today, plan, krs, bets, story, upstream, views, problems }.
|
|
29
|
+
krs[]: id, title, owner, unit, baseline, target, by, latest, latestValue, progress, stale, hash, checkins, upstream.
|
|
30
|
+
bets[]: id, owner, team, hypothesis, krs, starts, review_on, deadline, confidence, outcome, reviews, body, hash, delivery, releases.
|
|
31
|
+
upstream[] is read-only: plans synced from other repos. Never save to it.
|
|
32
|
+
|
|
33
|
+
window.nikos.save(kind, payload): Promise<{ wrote: string }>
|
|
34
|
+
"checkin": { id, expect, value, note?, evidence? }
|
|
35
|
+
"bet": { id, expect, fields: { owner?, team?, hypothesis?, starts?, review_on?, deadline?, confidence?, dependencies?, outcome? } }
|
|
36
|
+
"review": { id, expect, decision: "continue" | "change" | "stop" | "complete", reason, outcome?: "won" | "lost" }
|
|
37
|
+
|
|
38
|
+
expect 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/.
|
|
39
|
+
`;
|
|
40
|
+
/** The script every custom view gets. It saves through the same endpoints, with the same token and hash checks. */
|
|
41
|
+
const VIEW_SCRIPT = `(function () {
|
|
42
|
+
var meta = document.querySelector('meta[name="nikos-token"]');
|
|
43
|
+
var token = meta ? meta.getAttribute('content') : (new URLSearchParams(location.search).get('token') || '');
|
|
44
|
+
var paths = { checkin: '/api/checkin', bet: '/api/bet', review: '/api/review' };
|
|
45
|
+
window.nikos = {
|
|
46
|
+
data: function () {
|
|
47
|
+
return fetch('/api/data').then(function (r) {
|
|
48
|
+
if (!r.ok) throw new Error('Could not load the planning data (HTTP ' + r.status + ')');
|
|
49
|
+
return r.json();
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
save: function (kind, payload) {
|
|
53
|
+
var path = paths[kind];
|
|
54
|
+
if (!path) return Promise.reject(new Error('Unknown save kind "' + kind + '": use checkin, bet or review'));
|
|
55
|
+
return fetch(path, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json', 'X-Nikos-Token': token },
|
|
58
|
+
body: JSON.stringify(payload || {})
|
|
59
|
+
}).then(function (r) {
|
|
60
|
+
return r.json().catch(function () { return {}; }).then(function (body) {
|
|
61
|
+
if (!r.ok) {
|
|
62
|
+
var err = new Error(body.error || 'HTTP ' + r.status);
|
|
63
|
+
err.status = r.status;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
return body;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
})();
|
|
72
|
+
`;
|
|
73
|
+
/** The upstream KR a KR aligns to, found in the read-only copies. */
|
|
74
|
+
function upstreamRef(upstream, ref) {
|
|
75
|
+
if (!ref)
|
|
76
|
+
return null;
|
|
77
|
+
const [repo, id] = ref.split("#");
|
|
78
|
+
const plan = upstream.find((u) => u.repo === repo);
|
|
79
|
+
const kr = plan?.krs?.find((k) => k.id === id) ?? null;
|
|
80
|
+
return { ref, repo, commit: plan?.commit ?? null, found: kr !== null, kr, readOnly: true };
|
|
81
|
+
}
|
|
82
|
+
/** Everything the views need, as JSON. Files that fail to load are listed in `problems`, not thrown. */
|
|
83
|
+
export function planningData(root) {
|
|
84
|
+
const plan = loadPlan(root) ?? null;
|
|
85
|
+
const story = storyMapOf(root);
|
|
86
|
+
const upstream = loadUpstream(root);
|
|
87
|
+
const problems = [];
|
|
88
|
+
const krs = krIds(root).flatMap((id) => {
|
|
89
|
+
try {
|
|
90
|
+
const kr = loadKr(root, id);
|
|
91
|
+
const d = kr.data;
|
|
92
|
+
const last = latest(d) ?? null;
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
id: d.id ?? id,
|
|
96
|
+
title: d.title,
|
|
97
|
+
objective: d.objective ?? "",
|
|
98
|
+
owner: d.owner,
|
|
99
|
+
unit: d.unit ?? "",
|
|
100
|
+
baseline: d.baseline,
|
|
101
|
+
target: d.target,
|
|
102
|
+
original_target: d.original_target,
|
|
103
|
+
by: d.by,
|
|
104
|
+
stale_after_days: d.stale_after_days ?? 14,
|
|
105
|
+
aligns_to: d.aligns_to ?? null,
|
|
106
|
+
checkins: d.checkins ?? [],
|
|
107
|
+
latest: last,
|
|
108
|
+
latestValue: last ? last.value : d.baseline,
|
|
109
|
+
progress: progress(d),
|
|
110
|
+
stale: isStale(d),
|
|
111
|
+
hash: kr.hash,
|
|
112
|
+
file: kr.file,
|
|
113
|
+
upstream: upstreamRef(upstream, d.aligns_to),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
problems.push(err.message.split("\n")[0]);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const bets = betIds(root).flatMap((id) => {
|
|
123
|
+
try {
|
|
124
|
+
const bet = loadBet(root, id);
|
|
125
|
+
const m = bet.meta;
|
|
126
|
+
const d = delivery(root, id);
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
id: m.id ?? id,
|
|
130
|
+
owner: m.owner,
|
|
131
|
+
team: m.team ?? "",
|
|
132
|
+
hypothesis: m.hypothesis,
|
|
133
|
+
krs: { primary: m.krs?.primary ?? null, also: m.krs?.also ?? [] },
|
|
134
|
+
starts: m.starts ?? null,
|
|
135
|
+
review_on: m.review_on,
|
|
136
|
+
deadline: m.deadline ?? null,
|
|
137
|
+
confidence: m.confidence,
|
|
138
|
+
dependencies: m.dependencies ?? [],
|
|
139
|
+
outcome: m.outcome,
|
|
140
|
+
reviews: m.reviews ?? [],
|
|
141
|
+
body: bet.body.trim(),
|
|
142
|
+
hash: bet.hash,
|
|
143
|
+
file: bet.file,
|
|
144
|
+
delivery: { ...d, text: describeDelivery(d) },
|
|
145
|
+
releases: d.releases.map((n) => ({
|
|
146
|
+
number: n,
|
|
147
|
+
title: story.releases.find((r) => r.number === n)?.title ?? `Release ${n}`,
|
|
148
|
+
href: `/map#release-${n}`,
|
|
149
|
+
})),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
problems.push(err.message.split("\n")[0]);
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const viewsDir = paths(root).views;
|
|
159
|
+
const views = existsSync(viewsDir)
|
|
160
|
+
? readdirSync(viewsDir)
|
|
161
|
+
.filter((f) => VIEW_FILE.test(f))
|
|
162
|
+
.map((f) => f.slice(0, -".html".length))
|
|
163
|
+
.sort()
|
|
164
|
+
: [];
|
|
165
|
+
return {
|
|
166
|
+
project: plan?.name ?? (story.project || basename(root)),
|
|
167
|
+
today: today(),
|
|
168
|
+
plan,
|
|
169
|
+
krs,
|
|
170
|
+
bets,
|
|
171
|
+
story: { activities: story.activities, releases: story.releases, unreleased: story.unreleased },
|
|
172
|
+
upstream,
|
|
173
|
+
views,
|
|
174
|
+
problems,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Write the whole dashboard as one offline HTML file. Warns first when other repos' plans are inside. */
|
|
178
|
+
export function exportSnapshot(root, file) {
|
|
179
|
+
const out = resolve(file);
|
|
180
|
+
const upstream = loadUpstream(root);
|
|
181
|
+
const warned = upstream.length > 0;
|
|
182
|
+
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`);
|
|
185
|
+
}
|
|
186
|
+
writeFileSync(out, renderDashboard({ data: planningData(root), readOnly: true }));
|
|
187
|
+
return { wrote: out, warned };
|
|
188
|
+
}
|
|
189
|
+
/** Start the dashboard on 127.0.0.1 with a fresh token. The url carries the token for the browser. */
|
|
190
|
+
export async function startServer(root, opts = {}) {
|
|
191
|
+
const token = randomBytes(24).toString("hex");
|
|
192
|
+
let port = 0;
|
|
193
|
+
const server = createServer((req, res) => {
|
|
194
|
+
route(root, token, port, req, res).catch((err) => send(res, 500, { error: err.message }));
|
|
195
|
+
});
|
|
196
|
+
await new Promise((ok, fail) => {
|
|
197
|
+
server.once("error", fail);
|
|
198
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => ok());
|
|
199
|
+
});
|
|
200
|
+
port = server.address().port;
|
|
201
|
+
return {
|
|
202
|
+
url: `http://127.0.0.1:${port}/?token=${token}`,
|
|
203
|
+
token,
|
|
204
|
+
port,
|
|
205
|
+
close: () => new Promise((ok) => {
|
|
206
|
+
server.closeAllConnections();
|
|
207
|
+
server.close(() => ok());
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** `nikos planning serve`: run the dashboard until Ctrl+C, or export it to one file. */
|
|
212
|
+
export async function runServe(root, opts = {}) {
|
|
213
|
+
if (opts.exportTo) {
|
|
214
|
+
const { wrote } = exportSnapshot(root, opts.exportTo);
|
|
215
|
+
console.log(`Wrote ${wrote}`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const srv = await startServer(root, { port: opts.port });
|
|
219
|
+
console.log(`nikOS planning dashboard: ${srv.url}`);
|
|
220
|
+
console.log(`Saves write to ${relative(process.cwd(), paths(root).planning) || "planning"}/ only. Press Ctrl+C to stop.`);
|
|
221
|
+
return new Promise((ok) => {
|
|
222
|
+
const stop = () => {
|
|
223
|
+
srv.close().then(() => ok(0));
|
|
224
|
+
};
|
|
225
|
+
process.once("SIGINT", stop);
|
|
226
|
+
process.once("SIGTERM", stop);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function send(res, status, body, type = "application/json; charset=utf-8") {
|
|
230
|
+
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
231
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
232
|
+
res.end(text);
|
|
233
|
+
}
|
|
234
|
+
const sameToken = (given, token) => {
|
|
235
|
+
const a = Buffer.from(given), b = Buffer.from(token);
|
|
236
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
237
|
+
};
|
|
238
|
+
async function route(root, token, port, req, res) {
|
|
239
|
+
// Only this machine's own address: a page on another site can't reach the dashboard by DNS rebinding.
|
|
240
|
+
if (req.headers.host !== `127.0.0.1:${port}` && req.headers.host !== `localhost:${port}`) {
|
|
241
|
+
return send(res, 403, { error: "The dashboard only answers on its own address on this machine" });
|
|
242
|
+
}
|
|
243
|
+
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
244
|
+
if (req.method === "GET")
|
|
245
|
+
return serveGet(root, token, path, res);
|
|
246
|
+
if (req.method === "POST" && SAVES.has(path))
|
|
247
|
+
return saveRequest(root, token, path, req, res);
|
|
248
|
+
return send(res, 404, { error: "Not found" });
|
|
249
|
+
}
|
|
250
|
+
function serveGet(root, token, path, res) {
|
|
251
|
+
if (path === "/")
|
|
252
|
+
return send(res, 200, renderDashboard({ data: planningData(root), token, readOnly: false }), HTML);
|
|
253
|
+
if (path === "/api/data")
|
|
254
|
+
return send(res, 200, planningData(root));
|
|
255
|
+
if (path === "/map")
|
|
256
|
+
return send(res, 200, renderMap(root), HTML);
|
|
257
|
+
if (path === "/nikos-view.js")
|
|
258
|
+
return send(res, 200, VIEW_SCRIPT, JS);
|
|
259
|
+
const m = /^\/views\/([^/]+)$/.exec(path);
|
|
260
|
+
if (m && VIEW_NAME.test(m[1])) {
|
|
261
|
+
const file = join(paths(root).views, `${m[1]}.html`);
|
|
262
|
+
if (existsSync(file))
|
|
263
|
+
return send(res, 200, withViewScript(readFileSync(file, "utf8"), token), HTML);
|
|
264
|
+
}
|
|
265
|
+
return send(res, 404, { error: "Not found" });
|
|
266
|
+
}
|
|
267
|
+
/** Add the token and the contract script to a custom view's page. */
|
|
268
|
+
function withViewScript(html, token) {
|
|
269
|
+
const tags = `<meta name="nikos-token" content="${token}"><script src="/nikos-view.js"></script>`;
|
|
270
|
+
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${tags}</head>`) : tags + html;
|
|
271
|
+
}
|
|
272
|
+
const numberOf = (v) => (typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN);
|
|
273
|
+
const textOf = (v) => (typeof v === "string" ? v : undefined);
|
|
274
|
+
const SAVES = new Map([
|
|
275
|
+
["/api/checkin", (root, b) => checkIn(root, b.id, { value: numberOf(b.value), note: textOf(b.note), evidence: textOf(b.evidence), expect: b.expect })],
|
|
276
|
+
[
|
|
277
|
+
"/api/bet",
|
|
278
|
+
(root, b) => {
|
|
279
|
+
if (!b.fields || typeof b.fields !== "object" || Array.isArray(b.fields))
|
|
280
|
+
throw new Error("fields must be an object of bet fields to change");
|
|
281
|
+
return editBet(root, b.id, b.fields, b.expect);
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
["/api/review", (root, b) => reviewBet(root, b.id, { decision: b.decision, reason: textOf(b.reason) ?? "", outcome: b.outcome, expect: b.expect })],
|
|
285
|
+
]);
|
|
286
|
+
class TooLarge extends Error {
|
|
287
|
+
}
|
|
288
|
+
function readBody(req) {
|
|
289
|
+
return new Promise((ok, fail) => {
|
|
290
|
+
const chunks = [];
|
|
291
|
+
let size = 0;
|
|
292
|
+
req.on("data", (c) => {
|
|
293
|
+
size += c.length;
|
|
294
|
+
if (size > MAX_BODY)
|
|
295
|
+
return fail(new TooLarge("The request is too large"));
|
|
296
|
+
chunks.push(c);
|
|
297
|
+
});
|
|
298
|
+
req.on("end", () => ok(Buffer.concat(chunks).toString("utf8")));
|
|
299
|
+
req.on("error", fail);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async function saveRequest(root, token, path, req, res) {
|
|
303
|
+
const given = req.headers["x-nikos-token"];
|
|
304
|
+
if (typeof given !== "string" || !sameToken(given, token)) {
|
|
305
|
+
return send(res, 403, { error: "Missing or wrong token. Open the dashboard from the link nikos planning serve printed." });
|
|
306
|
+
}
|
|
307
|
+
let raw;
|
|
308
|
+
try {
|
|
309
|
+
raw = await readBody(req);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
res.setHeader("connection", "close");
|
|
313
|
+
return send(res, err instanceof TooLarge ? 413 : 400, { error: err.message });
|
|
314
|
+
}
|
|
315
|
+
let body;
|
|
316
|
+
try {
|
|
317
|
+
body = JSON.parse(raw);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return send(res, 400, { error: "The request body must be JSON" });
|
|
321
|
+
}
|
|
322
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
323
|
+
return send(res, 400, { error: "The request body must be a JSON object" });
|
|
324
|
+
if (typeof body.id !== "string" || !BET_ID.test(body.id))
|
|
325
|
+
return send(res, 400, { error: `'${String(body.id)}' is not an id` });
|
|
326
|
+
if (typeof body.expect !== "string")
|
|
327
|
+
return send(res, 400, { error: "expect (the hash of the version you loaded) is required. Reload the dashboard." });
|
|
328
|
+
try {
|
|
329
|
+
return send(res, 200, { wrote: SAVES.get(path)(root, body) });
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (err instanceof StaleError)
|
|
333
|
+
return send(res, 409, { error: err.message });
|
|
334
|
+
return send(res, 400, { error: err.message.split("\n")[0] });
|
|
335
|
+
}
|
|
336
|
+
}
|