nikos 0.0.7 → 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.
@@ -25,6 +25,12 @@ export function newStory(input, dir) {
25
25
  if (!activities.includes(input.activity)) {
26
26
  throw new Error(`'${input.activity}' is not an activity in storymap.yaml. Activities: ${activities.join(", ") || "none"}`);
27
27
  }
28
+ if (input.step !== undefined) {
29
+ const steps = ((plain.activities ?? []).find((a) => a?.id === input.activity)?.steps ?? []).map((st) => st?.id);
30
+ if (!steps.includes(input.step)) {
31
+ throw new Error(`'${input.step}' is not a step of activity '${input.activity}'. Steps: ${steps.join(", ") || "none (add one with nikos step add)"}`);
32
+ }
33
+ }
28
34
  const releases = plain.releases ?? [];
29
35
  if (!releases.length)
30
36
  throw new Error("storymap.yaml has no releases to add the story to");
@@ -44,7 +50,7 @@ export function newStory(input, dir) {
44
50
  else
45
51
  map.setIn(["releases", index, "stories"], map.createNode([id], { flow: true }));
46
52
  state.setIn(["stories", id], state.createNode({ status: "todo" }));
47
- const text = `@${id} @release-${release} @${priority} @activity-${input.activity}
53
+ const text = `@${id} @release-${release} @${priority} @activity-${input.activity}${input.step ? ` @step-${input.step}` : ""}
48
54
  Feature: ${title}
49
55
 
50
56
  As a <who it's for>
@@ -70,12 +76,12 @@ export function runStory(args) {
70
76
  return i >= 0 ? rest[i + 1] : undefined;
71
77
  };
72
78
  if (sub !== "add" || !title || title.startsWith("--") || !opt("activity")) {
73
- console.error('Usage: nikos story add "<title>" --activity <id> [--release <n>] [--priority must|should|could]');
79
+ console.error('Usage: nikos story add "<title>" --activity <id> [--step <id>] [--release <n>] [--priority must|should|could]');
74
80
  return 1;
75
81
  }
76
82
  try {
77
83
  const release = opt("release");
78
- const r = newStory({ title, activity: opt("activity"), release: release === undefined ? undefined : Number(release), priority: opt("priority") }, opt("dir") ?? process.cwd());
84
+ const r = newStory({ title, activity: opt("activity"), step: opt("step"), release: release === undefined ? undefined : Number(release), priority: opt("priority") }, opt("dir") ?? process.cwd());
79
85
  console.log(`Added ${r.id} to release ${r.release}\nCreated ${r.feature}`);
80
86
  console.log(`\nNext: write the user story and its scenarios in the feature file, then run nikos clear ${r.id}.`);
81
87
  return 0;
@@ -6,15 +6,14 @@ import { fileURLToPath } from "node:url";
6
6
  import { parse } from "yaml";
7
7
  import { lintFeature } from "../lint/gherkin.js";
8
8
  import { lintConstitution } from "../lint/constitution.js";
9
- import { parseBet } from "./bet.js";
10
- import { featureStoryIds, findIntentDir, read, rel, walk } from "../lib/project.js";
9
+ import { BET_ID, CROSS_REF, planningDir } from "../lib/bets.js";
10
+ import { featureStoryIds, featureTag, findIntentDir, read, rel, walk } from "../lib/project.js";
11
11
  const here = dirname(fileURLToPath(import.meta.url));
12
12
  // Works from src/ (tests) and dist/ (published): schemas sit at the package root.
13
13
  const schemaDir = [join(here, "../../schemas"), join(here, "../schemas")].find((d) => existsSync(d));
14
14
  const ajv = new Ajv2020({ allErrors: true, strict: false });
15
15
  const checkStorymap = ajv.compile(JSON.parse(read(join(schemaDir, "storymap.schema.json"))));
16
16
  const checkState = ajv.compile(JSON.parse(read(join(schemaDir, "state.schema.json"))));
17
- const checkBet = ajv.compile(JSON.parse(read(join(schemaDir, "bet.schema.json"))));
18
17
  function pointer(e) {
19
18
  return e.instancePath.replace(/^\//, "").split("/").filter(Boolean).join(".") || "(root)";
20
19
  }
@@ -77,9 +76,18 @@ export function validate(start) {
77
76
  // Cross-file checks only when both files parsed into objects.
78
77
  const mapStories = new Map();
79
78
  const activityIds = new Set();
79
+ // Each step id names its activity; ids are unique across the map so a @step tag is enough.
80
+ const stepActivity = new Map();
80
81
  if (storymap && typeof storymap === "object") {
81
- for (const a of storymap.activities ?? [])
82
+ for (const a of storymap.activities ?? []) {
82
83
  activityIds.add(a?.id);
84
+ for (const st of a?.steps ?? []) {
85
+ if (stepActivity.has(st?.id)) {
86
+ errors.push({ file: "storymap.yaml", message: `step '${st.id}' appears more than once`, hint: "give each step an id of its own across the map" });
87
+ }
88
+ stepActivity.set(st?.id, a?.id);
89
+ }
90
+ }
83
91
  const numbers = [];
84
92
  for (const r of storymap.releases ?? []) {
85
93
  numbers.push(r?.number);
@@ -132,6 +140,18 @@ export function validate(start) {
132
140
  if (storymap && !mapStories.has(id)) {
133
141
  errors.push({ file, message: `${id} is not in any release in storymap.yaml` });
134
142
  }
143
+ const step = featureTag(text, "step");
144
+ if (step && storymap) {
145
+ const owner = stepActivity.get(step);
146
+ if (!owner)
147
+ errors.push({ file, message: `@step-${step} is not a step in storymap.yaml`, hint: "add it under the activity's steps, or fix the tag" });
148
+ else if (owner !== activity)
149
+ errors.push({ file, message: `@step-${step} belongs to activity '${owner}', not '${activity}'` });
150
+ }
151
+ const release = featureTag(text, "release");
152
+ if (release && mapStories.has(id) && Number(release) !== mapStories.get(id)) {
153
+ warnings.push({ file, message: `is tagged @release-${release} but storymap.yaml puts ${id} in release ${mapStories.get(id)}`, hint: `move it with nikos release move ${id} <n>, which keeps both in step` });
154
+ }
135
155
  }
136
156
  const stateStories = (state && typeof state === "object" && state.stories) || {};
137
157
  if (storymap && state) {
@@ -153,34 +173,29 @@ export function validate(start) {
153
173
  errors.push({ file: "state.yaml", message: `run.queue has unknown story id ${id}` });
154
174
  }
155
175
  }
156
- // Bets: schema, id matches the file name, and every linked story is in the story map.
157
- for (const path of walk(join(dir, "bets"), ".md")) {
158
- const file = rel(dir, path);
159
- let bet;
160
- try {
161
- bet = parseBet(read(path));
162
- }
163
- catch (err) {
164
- errors.push({ file, message: `front matter isn't valid YAML: ${err.message.split("\n")[0]}` });
165
- continue;
166
- }
167
- if (!bet) {
168
- errors.push({ file, message: "has no front matter", hint: "create bets with `nikos bet new`" });
169
- continue;
170
- }
171
- if (!checkBet(bet.meta)) {
172
- for (const e of checkBet.errors ?? [])
173
- errors.push({ file, message: describe(e, bet.meta) });
174
- continue;
175
- }
176
- if (bet.meta.id !== basename(path, ".md")) {
177
- errors.push({ file, message: `id '${bet.meta.id}' doesn't match the file name`, hint: `rename the file to bets/${bet.meta.id}.md` });
178
- }
179
- if (storymap) {
180
- for (const id of bet.meta.stories) {
181
- if (!mapStories.has(id))
182
- errors.push({ file, message: `links ${id}, which is not in storymap.yaml` });
176
+ // Bet links: each names a bet in planning/bets/ or one in another repo (owner/repo#id).
177
+ if (storymap && typeof storymap === "object") {
178
+ const betsDir = join(planningDir(dir), "bets");
179
+ const check = (where, refs) => {
180
+ for (const ref of Array.isArray(refs) ? refs : []) {
181
+ if (typeof ref !== "string")
182
+ continue;
183
+ if (CROSS_REF.test(ref))
184
+ continue; // another repo's bet: checked for form only, never fetched
185
+ if (!BET_ID.test(ref)) {
186
+ errors.push({ file: "storymap.yaml", message: `${where} names bet '${ref}', which is neither a bet id nor owner/repo#id`, hint: "use a bet id like faster-checkout, or owner/repo#bet-id for another repo's bet" });
187
+ }
188
+ else if (!existsSync(join(betsDir, `${ref}.md`))) {
189
+ errors.push({ file: "storymap.yaml", message: `${where} names bet '${ref}', which has no planning/bets/${ref}.md`, hint: "create it with `nikos planning bet new`, or fix the name" });
190
+ }
183
191
  }
192
+ };
193
+ for (const r of storymap.releases ?? [])
194
+ check(`release ${r?.number}`, r?.bets);
195
+ for (const [id, refs] of Object.entries(storymap.story_bets ?? {})) {
196
+ if (!mapStories.has(id))
197
+ errors.push({ file: "storymap.yaml", message: `story_bets names ${id}, which is not in any release` });
198
+ check(id, refs);
184
199
  }
185
200
  }
186
201
  return { dir, errors, warnings, stories: mapStories.size, releases: storymap?.releases?.length ?? 0 };
@@ -0,0 +1,25 @@
1
+ // Bet links: releases (and, as an override, single stories) in storymap.yaml name
2
+ // the bets they deliver. The bets themselves live in planning/, outside intent/.
3
+ import { dirname, join } from "node:path";
4
+ /** A bet in this repo: lowercase words joined by hyphens. */
5
+ export const BET_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6
+ /** A bet or key result in another repo: owner/repo#id. */
7
+ export const CROSS_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[a-z0-9]+(-[a-z0-9]+)*$/;
8
+ export const isBetRef = (ref) => BET_ID.test(ref) || CROSS_REF.test(ref);
9
+ /** The repo's planning/ folder, next to intent/. */
10
+ export const planningDir = (intentDir) => join(dirname(intentDir), "planning");
11
+ /** The bets a story delivers: its own `story_bets` entry, or else its release's `bets`. */
12
+ export function betsFor(map, story) {
13
+ const release = (map?.releases ?? []).find((r) => (r?.stories ?? []).includes(story));
14
+ const own = map?.story_bets?.[story];
15
+ if (Array.isArray(own))
16
+ return { bets: own, from: "story", release: release?.number };
17
+ if (release && Array.isArray(release.bets) && release.bets.length)
18
+ return { bets: release.bets, from: "release", release: release.number };
19
+ return { bets: [], from: "none", release: release?.number };
20
+ }
21
+ /** Every story that delivers a bet, after story overrides. */
22
+ export function storiesFor(map, bet) {
23
+ const all = (map?.releases ?? []).flatMap((r) => r?.stories ?? []);
24
+ return all.filter((s) => betsFor(map, s).bets.includes(bet));
25
+ }
@@ -0,0 +1,8 @@
1
+ import { parse } from "yaml";
2
+ /** Split a Markdown file into its YAML front matter and body. */
3
+ export function parseFrontMatter(text) {
4
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
5
+ if (!m)
6
+ return undefined;
7
+ return { meta: parse(m[1]), body: text.slice(m[0].length) };
8
+ }
@@ -24,6 +24,43 @@ export function rel(dir, path) {
24
24
  return relative(dir, path) || path;
25
25
  }
26
26
  /** Story id tags on the lines above `Feature:`. */
27
+ /** The value of a `@<name>-<value>` tag above `Feature:`, e.g. featureTag(text, "step"). */
28
+ export function featureTag(text, name) {
29
+ const head = text.split(/^\s*Feature:/m)[0];
30
+ return new RegExp(`(?:^|\\s)@${name}-([A-Za-z0-9-]+)`).exec(head)?.[1];
31
+ }
32
+ /**
33
+ * Set (or with value undefined, remove) a `@<name>-<value>` tag above `Feature:`. An existing tag
34
+ * is replaced where it stands; a new one goes after the tag `after` (e.g. the story id), or last.
35
+ */
36
+ export function setFeatureTag(text, name, value, after) {
37
+ const lines = text.split("\n");
38
+ const at = lines.findIndex((l) => /^\s*Feature:/.test(l));
39
+ const tagLines = lines.slice(0, at < 0 ? 0 : at).flatMap((l, k) => (l.trim().startsWith("@") ? [k] : []));
40
+ if (!tagLines.length)
41
+ throw new Error("The feature has no tag line above Feature:");
42
+ const isOurs = (t) => t.startsWith(`@${name}-`);
43
+ const tagsOf = (k) => lines[k].trim().split(/\s+/).filter(Boolean);
44
+ const write = (k, tags) => (lines[k] = lines[k].match(/^\s*/)[0] + tags.join(" "));
45
+ const next = value === undefined ? [] : [`@${name}-${value}`];
46
+ let placed = false;
47
+ for (const k of tagLines) {
48
+ const tags = tagsOf(k);
49
+ const i = tags.findIndex(isOurs);
50
+ if (i < 0)
51
+ continue;
52
+ write(k, [...tags.slice(0, i), ...(placed ? [] : next), ...tags.slice(i + 1).filter((t) => !isOurs(t))]);
53
+ placed = true;
54
+ }
55
+ if (!placed && next.length) {
56
+ const k = tagLines.find((n) => after && tagsOf(n).includes(`@${after}`)) ?? tagLines[tagLines.length - 1];
57
+ const tags = tagsOf(k);
58
+ const j = after ? tags.indexOf(`@${after}`) : -1;
59
+ tags.splice(j >= 0 ? j + 1 : tags.length, 0, ...next);
60
+ write(k, tags);
61
+ }
62
+ return lines.join("\n");
63
+ }
27
64
  export function featureStoryIds(text) {
28
65
  const ids = [];
29
66
  for (const line of text.split("\n")) {
@@ -5,7 +5,7 @@ import { STORY_ID } from "../lib/project.js";
5
5
  const STATUS_TAGS = /^@(todo|done|wip|in[-_]?progress|doing|blocked|needs[-_]?rework|ready|review|approved|complete[d]?|skip|pending)$/i;
6
6
  const PRIORITY = /^@(must|should|could)$/;
7
7
  const AC_TAG = /^@AC\d+$/;
8
- const KNOWN_FEATURE_TAG = [STORY_ID, /^release-\d+$/, /^(must|should|could)$/, /^activity-[a-z0-9-]+$/, /^effort-(XS|S|M|L|XL)$/];
8
+ const KNOWN_FEATURE_TAG = [STORY_ID, /^release-\d+$/, /^(must|should|could)$/, /^activity-[a-z0-9-]+$/, /^step-[a-z0-9-]+$/, /^effort-(XS|S|M|L|XL)$/];
9
9
  function parser() {
10
10
  return new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher());
11
11
  }
@@ -42,7 +42,7 @@ export function lintFeature(text) {
42
42
  issues.push({ level: "error", message: `uses status tag ${tag}`, hint: "status lives in state.yaml, not in the feature" });
43
43
  }
44
44
  else if (!KNOWN_FEATURE_TAG.some((re) => re.test(tag.slice(1)))) {
45
- issues.push({ level: "warning", message: `has unrecognised tag ${tag}`, hint: "allowed: story id, @release-N, @must/@should/@could, @activity-<id>, @effort-<size>" });
45
+ issues.push({ level: "warning", message: `has unrecognised tag ${tag}`, hint: "allowed: story id, @release-N, @must/@should/@could, @activity-<id>, @step-<id>, @effort-<size>" });
46
46
  }
47
47
  }
48
48
  if (tags.filter((t) => PRIORITY.test(t)).length !== 1) {
@@ -0,0 +1,73 @@
1
+ // Bet cards in planning/bets/<id>.md: what we believe, the key results it should move,
2
+ // when we review it, and every review decision since.
3
+ import { join } from "node:path";
4
+ import { BET_ID } from "../lib/bets.js";
5
+ import { DATE, betText, checkBet, describeErrors, loadBet, paths, today, writePlanning } from "./model.js";
6
+ function checked(meta) {
7
+ if (!checkBet(meta))
8
+ throw new Error(`Not a valid bet card: ${describeErrors(checkBet.errors).join("; ")}`);
9
+ return meta;
10
+ }
11
+ /** Write planning/bets/<id>.md. Never overwrites an existing bet. */
12
+ export function newBet(input, root) {
13
+ if (!BET_ID.test(input.id))
14
+ throw new Error(`'${input.id}' is not a bet id (lowercase words joined by hyphens, e.g. faster-checkout)`);
15
+ if (!input.owner?.trim())
16
+ throw new Error("A bet needs an owner (--owner)");
17
+ if (!input.hypothesis?.trim())
18
+ throw new Error("A bet needs a hypothesis (--hypothesis)");
19
+ if (!DATE.test(input.reviewOn ?? ""))
20
+ throw new Error("A bet needs a review date (--review YYYY-MM-DD)");
21
+ const meta = checked({
22
+ id: input.id,
23
+ owner: input.owner.trim(),
24
+ ...(input.team ? { team: input.team } : {}),
25
+ hypothesis: input.hypothesis.trim(),
26
+ krs: { ...(input.primary ? { primary: input.primary } : {}), also: input.also ?? [] },
27
+ ...(input.starts ? { starts: input.starts } : {}),
28
+ review_on: input.reviewOn,
29
+ ...(input.deadline ? { deadline: input.deadline } : {}),
30
+ confidence: input.confidence ?? "medium",
31
+ dependencies: input.dependencies ?? [],
32
+ outcome: "open",
33
+ reviews: [],
34
+ });
35
+ const body = `\n## Why we think so\n${input.evidence?.trim() || "The evidence behind the hypothesis: what we saw, and where."}\n`;
36
+ return writePlanning(root, join(paths(root).bets, `${input.id}.md`), betText(meta, body), { create: true });
37
+ }
38
+ export const DECISIONS = ["continue", "change", "stop", "complete"];
39
+ /**
40
+ * Add a dated review decision to a bet. Earlier reviews are kept as they are.
41
+ * stop sets the outcome to stopped; complete needs the outcome the evidence shows (won or lost).
42
+ */
43
+ export function reviewBet(root, id, r) {
44
+ if (!DECISIONS.includes(r.decision))
45
+ throw new Error(`A review decision is one of ${DECISIONS.join(", ")}`);
46
+ if (!r.reason?.trim())
47
+ throw new Error("A review needs a reason (--why)");
48
+ if (r.decision === "complete" && r.outcome !== "won" && r.outcome !== "lost") {
49
+ throw new Error("Completing a bet needs its outcome from the key result evidence: --outcome won or --outcome lost");
50
+ }
51
+ const bet = loadBet(root, id);
52
+ const meta = structuredClone(bet.meta);
53
+ const outcome = r.decision === "stop" ? "stopped" : r.decision === "complete" ? r.outcome : undefined;
54
+ meta.reviews = [...(meta.reviews ?? []), { date: r.date ?? today(), decision: r.decision, reason: r.reason.trim(), ...(outcome ? { outcome } : {}) }];
55
+ if (outcome)
56
+ meta.outcome = outcome;
57
+ return writePlanning(root, join(paths(root).bets, `${id}.md`), betText(checked(meta), bet.body), { expect: r.expect ?? bet.hash });
58
+ }
59
+ const EDITABLE = ["owner", "team", "hypothesis", "starts", "review_on", "deadline", "confidence", "dependencies", "outcome"];
60
+ /** Change a bet's fields (not its reviews or id). `expect` is the hash of the version being edited. */
61
+ export function editBet(root, id, fields, expect) {
62
+ const bet = loadBet(root, id);
63
+ const meta = structuredClone(bet.meta);
64
+ for (const [k, v] of Object.entries(fields)) {
65
+ if (!EDITABLE.includes(k))
66
+ throw new Error(`'${k}' can't be edited here`);
67
+ if (v === "" || v === null)
68
+ delete meta[k];
69
+ else
70
+ meta[k] = v;
71
+ }
72
+ return writePlanning(root, join(paths(root).bets, `${id}.md`), betText(checked(meta), bet.body), { expect: expect ?? bet.hash });
73
+ }
@@ -0,0 +1,152 @@
1
+ // `nikos planning …`: the command line for the nikos-planning pack.
2
+ import { betIds, findRoot, krIds, loadBet, loadKr, validatePlanning } from "./model.js";
3
+ import { DECISIONS, newBet, reviewBet } from "./bets.js";
4
+ import { checkIn, isStale, latest, newKr, reviseTarget } from "./krs.js";
5
+ import { delivery, describeDelivery } from "./delivery.js";
6
+ import { crossRefs, runSync } from "./upstream.js";
7
+ import { runServe } from "./server.js";
8
+ const help = `nikos planning — bets, key results and the planning dashboard
9
+
10
+ Usage: nikos planning <command> [options] [--dir <repo>]
11
+
12
+ Commands:
13
+ bet new <id> Write planning/bets/<id>.md
14
+ (--owner, --hypothesis, --review YYYY-MM-DD, --kr <primary>, --also <ref>,
15
+ --team, --starts, --deadline, --confidence low|medium|high, --dependency, --evidence)
16
+ bet review <id> <continue|change|stop|complete> Add a dated review decision (--why, --outcome won|lost)
17
+ kr new <id> Write planning/krs/<id>.yaml
18
+ (--title, --owner, --baseline, --target, --by YYYY-MM-DD, --unit, --objective,
19
+ --aligns-to owner/repo#id, --stale-after <days>)
20
+ kr target <id> <n> Revise a key result's target; the original is kept
21
+ checkin <kr> <value> Add a dated check-in (--note, --evidence, --date)
22
+ status Every bet's delivery and outcome, every key result's latest value
23
+ validate Check planning/ against its schemas
24
+ sync [--check] Fetch the plans in planning/upstream.yaml read-only into .nikos/upstream/
25
+ serve Open the local dashboard (--port, --export <file>)`;
26
+ export function parseArgs(args) {
27
+ const positional = [];
28
+ const flags = new Map();
29
+ for (let i = 0; i < args.length; i++) {
30
+ const a = args[i];
31
+ if (a.startsWith("--")) {
32
+ const name = a.slice(2);
33
+ const next = args[i + 1];
34
+ const value = next !== undefined && !next.startsWith("--") ? (i++, next) : "true";
35
+ flags.set(name, [...(flags.get(name) ?? []), value]);
36
+ }
37
+ else
38
+ positional.push(a);
39
+ }
40
+ return { positional, all: (n) => flags.get(n) ?? [], opt: (n) => flags.get(n)?.[0], has: (n) => flags.has(n) };
41
+ }
42
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
43
+ const num = (s) => (s === undefined || s.trim() === "" ? NaN : Number(s));
44
+ export function status(root) {
45
+ const out = [];
46
+ const bets = betIds(root);
47
+ out.push(bets.length ? "Bets:" : "Bets: none");
48
+ for (const id of bets) {
49
+ const b = loadBet(root, id).meta;
50
+ out.push(` ${id} — delivery: ${describeDelivery(delivery(root, id))} · outcome: ${b.outcome} · review on ${b.review_on}`);
51
+ }
52
+ const krs = krIds(root);
53
+ out.push(krs.length ? "Key results:" : "Key results: none");
54
+ for (const id of krs) {
55
+ const k = loadKr(root, id).data;
56
+ const last = latest(k);
57
+ const u = k.unit ?? "";
58
+ out.push(` ${id} — ${k.title}: ${last ? `${last.value}${u} on ${last.date}` : "no check-ins"} (baseline ${k.baseline}${u}, target ${k.target}${u} by ${k.by})${isStale(k) ? " · stale" : ""}`);
59
+ }
60
+ return out.join("\n");
61
+ }
62
+ export async function runPlanning(args) {
63
+ const { positional, all, opt, has } = parseArgs(args);
64
+ const root = findRoot(opt("dir") ?? process.cwd());
65
+ const [cmd, sub, id, extra] = positional;
66
+ try {
67
+ switch (cmd) {
68
+ case undefined:
69
+ case "help":
70
+ console.log(help);
71
+ return 0;
72
+ case "bet":
73
+ if (sub === "new" && id) {
74
+ const path = newBet({
75
+ id,
76
+ owner: opt("owner") ?? "",
77
+ hypothesis: opt("hypothesis") ?? "",
78
+ reviewOn: opt("review") ?? "",
79
+ team: opt("team"),
80
+ starts: opt("starts"),
81
+ deadline: opt("deadline"),
82
+ primary: opt("kr"),
83
+ also: all("also"),
84
+ confidence: opt("confidence"),
85
+ dependencies: all("dependency"),
86
+ evidence: opt("evidence"),
87
+ }, root);
88
+ console.log(`Wrote ${path}`);
89
+ return 0;
90
+ }
91
+ if (sub === "review" && id && extra) {
92
+ if (!DECISIONS.includes(extra))
93
+ throw new Error(`A review decision is one of ${DECISIONS.join(", ")}`);
94
+ const path = reviewBet(root, id, { decision: extra, reason: opt("why") ?? "", outcome: opt("outcome") });
95
+ console.log(`Wrote ${path}`);
96
+ return 0;
97
+ }
98
+ break;
99
+ case "kr":
100
+ if (sub === "new" && id) {
101
+ const path = newKr({
102
+ id,
103
+ title: opt("title") ?? "",
104
+ owner: opt("owner") ?? "",
105
+ baseline: num(opt("baseline")),
106
+ target: num(opt("target")),
107
+ by: opt("by") ?? "",
108
+ unit: opt("unit"),
109
+ objective: opt("objective"),
110
+ alignsTo: opt("aligns-to"),
111
+ staleAfterDays: opt("stale-after") ? Number(opt("stale-after")) : undefined,
112
+ }, root);
113
+ console.log(`Wrote ${path}`);
114
+ return 0;
115
+ }
116
+ if (sub === "target" && id && extra !== undefined) {
117
+ console.log(`Wrote ${reviseTarget(root, id, num(extra))}`);
118
+ return 0;
119
+ }
120
+ break;
121
+ case "checkin":
122
+ if (sub && id !== undefined) {
123
+ console.log(`Wrote ${checkIn(root, sub, { value: num(id), note: opt("note"), evidence: opt("evidence"), date: opt("date") })}`);
124
+ return 0;
125
+ }
126
+ break;
127
+ case "status":
128
+ console.log(status(root));
129
+ return 0;
130
+ case "validate": {
131
+ const issues = validatePlanning(root);
132
+ for (const i of issues)
133
+ console.log(`✗ ${i.file}: ${i.message}`);
134
+ // Unresolved upstream references are reported, never fatal: planning carries on without them.
135
+ for (const r of crossRefs(root).filter((r) => !r.resolved))
136
+ console.log(`? ${r.file}: ${r.ref} is unresolved (not synced or not accessible)`);
137
+ console.log(issues.length ? `\n${issues.length} ${issues.length === 1 ? "problem" : "problems"} found.` : `✓ planning/ is valid — ${count(betIds(root).length, "bet")}, ${count(krIds(root).length, "key result")}`);
138
+ return issues.length ? 1 : 0;
139
+ }
140
+ case "sync":
141
+ return await runSync(root, { check: has("check") });
142
+ case "serve":
143
+ return await runServe(root, { port: opt("port") ? Number(opt("port")) : undefined, exportTo: opt("export") });
144
+ }
145
+ console.error(`Unknown planning command.\n\n${help}`);
146
+ return 1;
147
+ }
148
+ catch (err) {
149
+ console.error(`✗ ${err.message}`);
150
+ return 1;
151
+ }
152
+ }
@@ -0,0 +1,20 @@
1
+ // Delivery status of a bet, worked out from the story map: the stories whose release (or
2
+ // own story_bets) names the bet, and their status in state.yaml. Never stored, never the outcome.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parse } from "yaml";
6
+ import { storiesFor } from "../lib/bets.js";
7
+ import { paths } from "./model.js";
8
+ const yamlAt = (p) => (existsSync(p) ? parse(readFileSync(p, "utf8")) : undefined);
9
+ export function delivery(root, bet) {
10
+ const intent = paths(root).intent;
11
+ const map = yamlAt(join(intent, "storymap.yaml"));
12
+ const state = yamlAt(join(intent, "state.yaml"));
13
+ const ids = map ? storiesFor(map, bet) : [];
14
+ const stories = ids.map((id) => ({ id, status: state?.stories?.[id]?.status ?? "todo" }));
15
+ const done = stories.filter((s) => s.status === "done").length;
16
+ const releases = [...new Set((map?.releases ?? []).filter((r) => (r?.stories ?? []).some((s) => ids.includes(s))).map((r) => r.number))];
17
+ const status = !stories.length ? "Not started" : done === stories.length ? "Shipped" : "Building";
18
+ return { status, done, total: stories.length, stories, releases };
19
+ }
20
+ export const describeDelivery = (d) => d.status === "Building" ? `Building, ${d.done} of ${d.total} done` : d.status === "Shipped" ? `Shipped, ${d.total} of ${d.total} done` : "Not started";
@@ -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
+ }