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.
Files changed (39) hide show
  1. package/README.md +5 -4
  2. package/dist/cli.js +21 -5
  3. package/dist/commands/map.js +272 -0
  4. package/dist/commands/release.js +170 -0
  5. package/dist/commands/show.js +7 -7
  6. package/dist/commands/step.js +59 -0
  7. package/dist/commands/story.js +9 -3
  8. package/dist/commands/validate.js +46 -31
  9. package/dist/commands/video.js +116 -0
  10. package/dist/lib/bets.js +25 -0
  11. package/dist/lib/frontmatter.js +8 -0
  12. package/dist/lib/project.js +37 -0
  13. package/dist/lint/gherkin.js +2 -2
  14. package/dist/planning/bets.js +73 -0
  15. package/dist/planning/cli.js +152 -0
  16. package/dist/planning/delivery.js +20 -0
  17. package/dist/planning/krs.js +73 -0
  18. package/dist/planning/model.js +164 -0
  19. package/dist/planning/server.js +336 -0
  20. package/dist/planning/ui.js +452 -0
  21. package/dist/planning/upstream.js +175 -0
  22. package/docs/format.md +50 -55
  23. package/docs/gherkin.md +2 -1
  24. package/docs/install.md +26 -5
  25. package/docs/planning.md +124 -0
  26. package/package.json +2 -1
  27. package/schemas/planning/bet.schema.json +42 -0
  28. package/schemas/planning/kr.schema.json +35 -0
  29. package/schemas/planning/plan.schema.json +28 -0
  30. package/schemas/storymap.schema.json +94 -11
  31. package/skills/nikos-mode/playbooks/idea-to-blueprint.md +1 -1
  32. package/skills/nikos-mode/playbooks/index.md +2 -1
  33. package/skills/nikos-mode/playbooks/planning.md +11 -0
  34. package/skills/nikos-mode/playbooks/video.md +14 -0
  35. package/skills/nikos-mode/principles/index.md +0 -2
  36. package/dist/commands/bet.js +0 -97
  37. package/docs/intentdocs-mapping.md +0 -117
  38. package/schemas/bet.schema.json +0 -23
  39. package/skills/nikos-mode/playbooks/bet.md +0 -14
@@ -0,0 +1,452 @@
1
+ // The dashboard page: one HTML file with the data embedded as JSON. Inline CSS and vanilla
2
+ // JS draw the views, so the live dashboard and the offline export are the same page. A
3
+ // read-only render leaves out the save code and the token altogether.
4
+ import { esc } from "../commands/map.js";
5
+ const CSS = `
6
+ :root { --ground: #F2F3EF; --surface: #FFFFFF; --ink: #111411; --ink-2: #3F453F; --muted: #5C625C; --faint: #9AA19A; --line: #DCDFD8; --line-2: #B9BEB6; --accent: #14823E; --accent-soft: #E2EFE5; --amber: #A86A00; --amber-soft: #FBF1DC; --blue: #1F5FA8; --blue-soft: #E3ECF7; --grey-soft: #ECEEEA; --red: #8A2E1F; }
7
+ * { box-sizing: border-box; }
8
+ body { margin: 0; background: var(--ground); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
9
+ .shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
10
+ .side { background: var(--surface); border-right: 1px solid var(--line); padding: 20px 14px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; overflow-y: auto; }
11
+ .links { margin-top: auto; padding-top: 16px; }
12
+ .links a { padding: 0 10px; color: var(--muted); text-decoration: none; font-size: 12px; }
13
+ .links a:hover { color: var(--ink-2); text-decoration: underline; }
14
+ .brand { font-weight: 700; letter-spacing: .02em; }
15
+ .project { color: var(--muted); font-size: 13px; margin-bottom: 14px; }
16
+ nav a { display: block; padding: 6px 10px; border-radius: 6px; color: var(--ink-2); text-decoration: none; font-size: 14px; }
17
+ nav a.on, nav a:hover { background: var(--accent-soft); color: var(--ink); }
18
+ nav h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 16px 10px 4px; }
19
+ .content { padding: 24px 32px 60px; max-width: 980px; min-width: 0; }
20
+ .mode { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
21
+ h1 { font-size: 22px; margin: 4px 0 6px; }
22
+ h2 { font-size: 17px; margin: 0; }
23
+ h3 { font-size: 14px; margin: 0 0 8px; }
24
+ h4 { font-size: 14px; margin: 14px 0 4px; }
25
+ a { color: var(--blue); }
26
+ .muted { color: var(--muted); font-size: 13px; }
27
+ .back { font-size: 13px; text-decoration: none; }
28
+ .card, .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 12px; color: inherit; text-decoration: none; display: block; }
29
+ a.card:hover { border-color: var(--line-2); }
30
+ .cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
31
+ .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
32
+ .big { font-size: 26px; font-weight: 700; }
33
+ .progress { height: 8px; background: var(--line); border-radius: 99px; overflow: hidden; margin-top: 10px; }
34
+ .progress > div { height: 100%; background: var(--accent); }
35
+ .chip { display: inline-block; font-size: 12px; padding: 2px 9px; border-radius: 99px; background: var(--grey-soft); color: var(--ink-2); border: 1px solid transparent; text-decoration: none; }
36
+ .chip.green { background: var(--accent-soft); color: var(--accent); }
37
+ .chip.amber { background: var(--amber-soft); color: var(--amber); }
38
+ .chip.blue { background: var(--blue-soft); color: var(--blue); }
39
+ .chip.lock { background: var(--surface); border-color: var(--line-2); color: var(--muted); }
40
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 14px 0; }
41
+ .stat { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
42
+ .label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
43
+ .split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 12px 0; }
44
+ .box { border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
45
+ .box.delivery { background: #F6F7F3; }
46
+ .box.outcome { background: var(--surface); }
47
+ .val { font-weight: 600; margin: 2px 0 6px; }
48
+ .hyp { font-size: 16px; margin: 10px 0 4px; }
49
+ .prose { white-space: pre-line; color: var(--ink-2); font-size: 14px; margin-top: 8px; }
50
+ .stories { list-style: none; padding: 0; margin: 6px 0; font-size: 13px; }
51
+ .sid { font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--muted); margin-right: 4px; }
52
+ .reviews { padding-left: 18px; font-size: 13px; margin: 6px 0 0; }
53
+ .form { display: grid; gap: 10px; }
54
+ .form label { display: grid; gap: 4px; font-size: 13px; color: var(--ink-2); }
55
+ .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
56
+ input, select, textarea { font: inherit; padding: 7px 9px; border: 1px solid var(--line-2); border-radius: 6px; background: var(--surface); color: var(--ink); width: 100%; }
57
+ textarea { resize: vertical; }
58
+ button { font: inherit; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); cursor: pointer; }
59
+ button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
60
+ .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
61
+ .chart { width: 100%; height: auto; display: block; }
62
+ .chart .base { stroke: var(--faint); stroke-width: 1.5; }
63
+ .chart .target { stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 6 4; }
64
+ .chart .line { fill: none; stroke: var(--blue); stroke-width: 2.5; }
65
+ .chart .pt { fill: var(--blue); }
66
+ .chart .ax { font-size: 11px; fill: var(--muted); }
67
+ .checkins { list-style: none; padding: 0; margin: 0; font-size: 13px; }
68
+ .checkins li { padding: 6px 0; border-bottom: 1px solid var(--line); }
69
+ .timeline { --label: 170px; overflow-x: auto; }
70
+ .axis { position: relative; height: 20px; margin-left: var(--label); font-size: 11px; color: var(--muted); }
71
+ .axis span { position: absolute; white-space: nowrap; }
72
+ .lane { display: flex; border-top: 1px solid var(--line); min-height: 36px; }
73
+ .tlabel { width: var(--label); flex: none; padding: 8px 10px 8px 0; font-size: 13px; color: var(--ink-2); }
74
+ .track { position: relative; flex: 1; min-height: 36px; }
75
+ .tbar { position: absolute; top: 7px; height: 22px; line-height: 22px; padding: 0 7px; border-radius: 5px; font-size: 12px; color: #fff; background: var(--blue); white-space: nowrap; overflow: hidden; text-decoration: none; }
76
+ .tbar.won { background: var(--accent); }
77
+ .tbar.awaiting { background: var(--amber); }
78
+ .tbar.closed { background: #7C827B; }
79
+ .today { position: absolute; top: 0; bottom: 0; border-left: 2px solid var(--ink); opacity: .45; }
80
+ .alts { display: flex; flex-direction: column; }
81
+ .alt { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--surface); }
82
+ .alt.here { border-color: var(--accent); background: var(--accent-soft); }
83
+ .alt.delivery { background: #F6F7F3; }
84
+ .alt .row { margin-bottom: 2px; }
85
+ .alt-arrow { font-size: 12px; color: var(--muted); padding: 4px 12px; }
86
+ .steps { margin: 0; padding-left: 20px; font-size: 14px; color: var(--ink-2); display: grid; gap: 8px; }
87
+ .notice { background: var(--amber-soft); color: var(--amber); border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
88
+ .toast { position: fixed; right: 20px; bottom: 20px; max-width: 360px; background: var(--ink); color: #fff; padding: 10px 14px; border-radius: 8px; opacity: 0; pointer-events: none; transition: opacity .2s; font-size: 14px; }
89
+ .toast.show { opacity: 1; }
90
+ .toast.bad { background: var(--red); }
91
+ @media (max-width: 720px) {
92
+ .shell { grid-template-columns: 1fr; }
93
+ .side { border-right: 0; border-bottom: 1px solid var(--line); position: static; height: auto; }
94
+ .links { margin-top: 16px; }
95
+ .content { padding: 16px 12px 48px; }
96
+ .split, .fields { grid-template-columns: 1fr; }
97
+ .timeline { --label: 96px; }
98
+ }
99
+ `;
100
+ // Shared view code (drawn in both the live dashboard and the export). Written with String.raw:
101
+ // no backticks or substitutions inside.
102
+ const VIEWS_JS = String.raw `
103
+ var D = JSON.parse(document.getElementById('nikos-data').textContent);
104
+ var TODAY = D.today;
105
+ var DAY = 86400000;
106
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
107
+ var main = document.getElementById('main');
108
+
109
+ function ts(d) { return Date.parse(d + 'T00:00:00Z'); }
110
+ function addDays(d, n) { return new Date(ts(d) + n * DAY).toISOString().slice(0, 10); }
111
+ function dayDiff(a, b) { return Math.round((ts(b) - ts(a)) / DAY); }
112
+ function num(n, unit) {
113
+ if (n === null || n === undefined || isNaN(n)) return '-';
114
+ var s = String(Math.round(n * 100) / 100);
115
+ return unit ? (unit === '%' ? s + '%' : s + ' ' + unit) : s;
116
+ }
117
+ function esc(s) { return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
118
+ function chip(text, cls) { return '<span class="chip ' + cls + '">' + esc(text) + '</span>'; }
119
+ function krById(id) { for (var i = 0; i < D.krs.length; i++) if (D.krs[i].id === id) return D.krs[i]; return null; }
120
+ function outcomeClass(o) { return o === 'won' ? 'green' : o === 'awaiting evidence' ? 'amber' : o === 'open' ? 'blue' : ''; }
121
+ function barClass(o) { return o === 'won' ? 'won' : o === 'awaiting evidence' ? 'awaiting' : (o === 'lost' || o === 'stopped') ? 'closed' : ''; }
122
+ function render() {
123
+ var p = (location.hash || '#/krs').replace(/^#\/?/, '').split('/');
124
+ var section = p[0] === 'kr' ? 'krs' : (p[0] || 'krs');
125
+ var links = document.querySelectorAll('[data-nav]');
126
+ for (var i = 0; i < links.length; i++) links[i].classList.toggle('on', links[i].getAttribute('data-nav') === section);
127
+ var html;
128
+ if (p[0] === 'kr' && p[1]) html = krDetail(p[1]);
129
+ else if (p[0] === 'bets') html = betsView();
130
+ else if (p[0] === 'timeline') html = timelineView();
131
+ else if (p[0] === 'guide') html = guideView();
132
+ else html = krsView();
133
+ 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
+
136
+ window.addEventListener('hashchange', render);
137
+
138
+ var LEVELS = [
139
+ ['company', 'Company', 'Sets the few outcomes that matter most this period, as key results.'],
140
+ ['department', 'Department', 'Picks key results that move the company ones, and links each one up.'],
141
+ ['group', 'Group', 'Does the same for its area, aligned to the department.'],
142
+ ['team', 'Team', 'Makes bets on what will move its key results, and builds them as stories.']
143
+ ];
144
+
145
+ function guideView() {
146
+ var level = D.plan && D.plan.level ? String(D.plan.level) : '';
147
+ var ups = (D.upstream || []).map(function (u) { return u.repo; });
148
+ var here = level
149
+ ? 'This is a ' + esc(level) + ' plan' + (D.plan.period ? ' for ' + esc(D.plan.period) : '') + (ups.length ? ', working toward ' + ups.map(esc).join(', ') : '') + '.'
150
+ : 'This repo has no plan.yaml yet. Ask your agent to plan the period and it will set the level.';
151
+ var stack = LEVELS.map(function (l, i) {
152
+ return (i ? '<div class="alt-arrow" aria-hidden="true">↑ aligns to, read-only</div>' : '') +
153
+ '<div class="alt' + (l[0] === level ? ' here' : '') + '"><div class="row"><strong>' + l[1] + '</strong>' + (l[0] === level ? chip('This plan', 'green') : '') +
154
+ '<span class="muted">its own repo, planning/</span></div><div class="muted">' + l[2] + '</div></div>';
155
+ }).join('') +
156
+ '<div class="alt-arrow" aria-hidden="true">↑ delivers bets</div>' +
157
+ '<div class="alt delivery"><div class="row"><strong>Story map</strong><span class="muted">intent/ in the team repo</span></div><div class="muted">Releases name the bets they deliver. Story status comes from the work itself.</div></div>';
158
+ var steps = [
159
+ ['Plan the period', 'Ask your agent to plan the quarter. It asks for the level, pillars, objectives and key results, and shows a draft. Nothing is written until you approve.'],
160
+ ['Align upward', 'List the plans above you in planning/upstream.yaml. nikos planning sync keeps a read-only copy, so a key result can align to the company one by reference without ever writing to that repo.'],
161
+ ['Frame bets', 'A bet says what you believe will move a key result, and why, with the evidence. It has a review date, not a deadline.'],
162
+ ['Deliver through the story map', 'A release in intent/storymap.yaml names the bets it delivers. Delivery status (Not started, Building, Shipped) is worked out from its stories.'],
163
+ ['Check in', 'Record the key result value as it moves, by chat or here. A key result with no recent check-in is flagged stale.'],
164
+ ['Review and call the outcome', 'On the review date, continue, change, stop or complete the bet, with the reason. The outcome (won, lost) comes only from key result evidence: shipped is not won.']
165
+ ].map(function (s, i) { return '<li><strong>' + esc(s[0]) + '.</strong> ' + esc(s[1]) + '</li>'; }).join('');
166
+ var value = [
167
+ ['Plans live next to the work', 'Objectives, key results and bets are plain files in the repo, reviewed in pull requests and versioned with the code.'],
168
+ ['Delivery is never self-reported', 'Whether a bet is building or shipped comes from the story map and its stories, not from a status someone typed.'],
169
+ ['Outcomes are kept honest', 'A bet is only won when its key result moves. A revised target keeps the original one.'],
170
+ ['Every level lines up, and nobody loses control', 'Teams align to the plans above by reference. Repo access is the permission model, and no plan writes to another.'],
171
+ ['Your agent runs the rituals', 'Planning, check-ins and reviews are conversations with your coding agent, with the same checks as the commands.']
172
+ ].map(function (v) { return '<div class="panel"><h3>' + esc(v[0]) + '</h3><div class="muted">' + esc(v[1]) + '</div></div>'; }).join('');
173
+ return '<h1>How it works</h1><p class="muted">' + here + '</p>' +
174
+ '<div class="panel"><h3>One plan per level, each in its own repo</h3><div class="alts">' + stack + '</div></div>' +
175
+ '<div class="panel"><h3>The loop</h3><ol class="steps">' + steps + '</ol></div>' +
176
+ '<h2 style="margin:20px 0 10px">What nikOS adds</h2><div class="cards">' + value + '</div>';
177
+ }
178
+
179
+ function krCard(k) {
180
+ var chips = '';
181
+ if (k.stale) chips += chip('Stale', 'amber');
182
+ if (k.upstream) chips += chip('Aligned to ' + k.upstream.ref, 'lock');
183
+ var pct = Math.max(0, Math.min(100, Math.round(k.progress * 100)));
184
+ return '<a class="card" href="#/kr/' + esc(k.id) + '">' +
185
+ '<div class="row"><strong>' + esc(k.title) + '</strong>' + chips + '</div>' +
186
+ '<div class="muted">' + esc(k.owner) + ' · by ' + esc(k.by) + '</div>' +
187
+ '<div class="row" style="margin-top:8px"><span class="big">' + esc(num(k.latestValue, k.unit)) + '</span><span class="muted">target ' + esc(num(k.target, k.unit)) + '</span></div>' +
188
+ '<div class="progress"><div style="width:' + pct + '%"></div></div>' +
189
+ '<div class="muted" style="margin-top:6px">' + (k.latest ? 'Latest check-in ' + esc(k.latest.date) : 'No check-ins yet') + '</div></a>';
190
+ }
191
+
192
+ function krsView() {
193
+ if (!D.krs.length) return '<h1>Key results</h1><p class="muted">No key results yet. Add one with nikos planning kr new.</p>';
194
+ return '<h1>Key results</h1><p class="muted">Progress runs from the baseline to the target.</p><div class="cards">' + D.krs.map(krCard).join('') + '</div>';
195
+ }
196
+
197
+ function chartSvg(k) {
198
+ var W = 640, H = 260, L = 56, R = 16, T = 16, B = 30;
199
+ var dates = k.checkins.map(function (c) { return c.date; }).concat([k.by, TODAY]);
200
+ var start = dates.reduce(function (a, b) { return ts(b) < ts(a) ? b : a; });
201
+ var end = dates.reduce(function (a, b) { return ts(b) > ts(a) ? b : a; });
202
+ if (start === end) end = addDays(start, 1);
203
+ var values = k.checkins.map(function (c) { return c.value; }).concat([k.baseline, k.target]);
204
+ var lo = Math.min.apply(null, values), hi = Math.max.apply(null, values);
205
+ if (hi === lo) { lo -= 1; hi += 1; }
206
+ var pad = (hi - lo) * 0.12; lo -= pad; hi += pad;
207
+ var x = function (d) { return L + (ts(d) - ts(start)) / (ts(end) - ts(start)) * (W - L - R); };
208
+ var y = function (v) { return T + (1 - (v - lo) / (hi - lo)) * (H - T - B); };
209
+ var s = '<svg class="chart" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Check-ins over time against baseline and target">';
210
+ s += '<line class="base" x1="' + L + '" x2="' + (W - R) + '" y1="' + y(k.baseline) + '" y2="' + y(k.baseline) + '"></line>';
211
+ s += '<text class="ax" x="' + (W - R) + '" y="' + (y(k.baseline) - 5) + '" text-anchor="end">baseline ' + esc(num(k.baseline, k.unit)) + '</text>';
212
+ s += '<line class="target" x1="' + L + '" x2="' + (W - R) + '" y1="' + y(k.target) + '" y2="' + y(k.target) + '"></line>';
213
+ s += '<text class="ax" x="' + (W - R) + '" y="' + (y(k.target) - 5) + '" text-anchor="end">target ' + esc(num(k.target, k.unit)) + ' by ' + esc(k.by) + '</text>';
214
+ if (k.checkins.length) s += '<polyline class="line" points="' + k.checkins.map(function (c) { return x(c.date) + ',' + y(c.value); }).join(' ') + '"></polyline>';
215
+ k.checkins.forEach(function (c) {
216
+ s += '<circle class="pt" cx="' + x(c.date) + '" cy="' + y(c.value) + '" r="4"><title>' + esc(c.date + ': ' + num(c.value, k.unit)) + '</title></circle>';
217
+ });
218
+ s += '<text class="ax" x="' + L + '" y="' + (H - 8) + '">' + esc(start) + '</text>';
219
+ s += '<text class="ax" x="' + (W - R) + '" y="' + (H - 8) + '" text-anchor="end">' + esc(end) + '</text>';
220
+ s += '<text class="ax" x="' + (L - 6) + '" y="' + (y(hi) + 4) + '" text-anchor="end">' + esc(num(Math.round(hi - pad), '')) + '</text>';
221
+ s += '<text class="ax" x="' + (L - 6) + '" y="' + (y(lo) + 4) + '" text-anchor="end">' + esc(num(Math.round(lo + pad), '')) + '</text>';
222
+ return s + '</svg>';
223
+ }
224
+
225
+ function krDetail(id) {
226
+ var k = krById(id);
227
+ if (!k) return '<p>No key result called ' + esc(id) + '.</p>';
228
+ var upstream = '';
229
+ if (k.upstream) {
230
+ upstream = '<div class="panel"><div class="label">Aligned to ' + esc(k.upstream.ref) + ' (read-only copy)</div><div class="val">' +
231
+ (k.upstream.kr ? esc(k.upstream.kr.title) : 'Not in the synced copy yet') + '</div></div>';
232
+ }
233
+ var checkins = k.checkins.slice().reverse().map(function (c) {
234
+ return '<li><span class="muted">' + esc(c.date) + '</span> <strong>' + esc(num(c.value, k.unit)) + '</strong>' +
235
+ (c.note ? ' · ' + esc(c.note) : '') + (c.evidence ? ' · <span class="muted">' + esc(c.evidence) + '</span>' : '') + '</li>';
236
+ }).join('');
237
+ return '<a class="back" href="#/krs">← Key results</a>' +
238
+ '<h1>' + esc(k.title) + '</h1>' +
239
+ '<div class="muted">' + esc(k.objective) + (k.objective ? ' · ' : '') + 'Owner ' + esc(k.owner) + ' · by ' + esc(k.by) + '</div>' +
240
+ '<div class="stats">' +
241
+ '<div class="stat"><div class="label">Latest</div><div class="big">' + esc(num(k.latestValue, k.unit)) + '</div><div class="muted">' + (k.latest ? 'on ' + esc(k.latest.date) : 'no check-ins') + '</div></div>' +
242
+ '<div class="stat"><div class="label">Baseline to target</div><div class="val">' + esc(num(k.baseline, k.unit)) + ' to ' + esc(num(k.target, k.unit)) + '</div><div class="muted">' + Math.round(k.progress * 100) + '% of the way</div></div>' +
243
+ '<div class="stat"><div class="label">Status</div><div class="val">' + (k.stale ? chip('Stale: no recent check-in', 'amber') : chip('Checked in recently', 'green')) + '</div></div>' +
244
+ '</div>' + upstream +
245
+ '<div class="panel"><h3>Over time</h3>' + chartSvg(k) + '</div>' +
246
+ (LIVE ? checkinForm(k) : '') +
247
+ '<div class="panel"><h3>Check-ins</h3>' + (checkins ? '<ul class="checkins">' + checkins + '</ul>' : '<p class="muted">No check-ins yet.</p>') + '</div>';
248
+ }
249
+
250
+ function checkinForm(k) {
251
+ var last = k.latest ? k.latest.value : k.baseline;
252
+ return '<div class="panel form" data-form="checkin" data-id="' + esc(k.id) + '" data-hash="' + esc(k.hash) + '"><h3>Check in</h3>' +
253
+ '<div class="fields"><label>Value' + (k.unit ? ' (' + esc(k.unit) + ')' : '') + '<input name="value" type="number" step="any" value="' + esc(last) + '"></label>' +
254
+ '<label>Evidence link (optional)<input name="evidence" type="text"></label></div>' +
255
+ '<label>Note (optional)<textarea name="note" rows="2"></textarea></label>' +
256
+ '<div class="actions"><button class="primary" data-act="checkin">Check in</button><span class="muted">Writes ' + esc(k.file) + '</span></div></div>';
257
+ }
258
+
259
+ function betsView() {
260
+ if (!D.bets.length) return '<h1>Bets</h1><p class="muted">No bets yet. Write one with nikos planning bet new.</p>';
261
+ return '<h1>Bets</h1><p class="muted">What we believe, what is delivered, and what it moved.</p>' + D.bets.map(betCard).join('');
262
+ }
263
+
264
+ function betCard(b) {
265
+ var d = b.delivery;
266
+ var refs = [];
267
+ if (b.krs.primary) refs.push(b.krs.primary);
268
+ b.krs.also.forEach(function (r) { refs.push(r); });
269
+ var krLinks = refs.map(function (r) {
270
+ return krById(r) ? '<a class="chip" href="#/kr/' + esc(r) + '">' + esc(r) + '</a>' : chip(r, 'lock');
271
+ }).join(' ') || '<span class="muted">No key result linked</span>';
272
+ var releases = b.releases.length ? b.releases.map(function (r) {
273
+ return LIVE ? '<a class="chip blue" href="' + esc(r.href) + '">Release ' + r.number + '</a>' : chip('Release ' + r.number, 'blue');
274
+ }).join(' ') : '<span class="muted">Not on any release</span>';
275
+ var stories = d.stories.map(function (s) {
276
+ return '<li><span class="sid">' + esc(s.id) + '</span>' + esc(s.status.replace(/_/g, ' ')) + '</li>';
277
+ }).join('');
278
+ var reviews = b.reviews.length ? '<ol class="reviews">' + b.reviews.slice().reverse().map(function (r) {
279
+ return '<li><span class="muted">' + esc(r.date) + '</span> <strong>' + esc(r.decision) + '</strong>' + (r.outcome ? ' (' + esc(r.outcome) + ')' : '') + ': ' + esc(r.reason) + '</li>';
280
+ }).join('') + '</ol>' : '<p class="muted">No reviews yet.</p>';
281
+ return '<article class="card" id="bet-' + esc(b.id) + '" style="cursor:default">' +
282
+ '<div class="row"><h2>' + esc(b.id) + '</h2>' + chip(b.outcome, outcomeClass(b.outcome)) + chip('Confidence ' + b.confidence, '') + '</div>' +
283
+ '<p class="hyp">' + esc(b.hypothesis) + '</p>' +
284
+ '<div class="muted">Owner ' + esc(b.owner) + (b.team ? ' · Team ' + esc(b.team) : '') + ' · Review ' + esc(b.review_on) + (b.deadline ? ' · Deadline ' + esc(b.deadline) : '') + '</div>' +
285
+ '<div class="split">' +
286
+ '<div class="box delivery"><div class="label">Delivery</div><div class="val">' + esc(d.text) + '</div>' +
287
+ (stories ? '<ul class="stories">' + stories + '</ul>' : '') + '<div class="row">' + releases + '</div></div>' +
288
+ '<div class="box outcome"><div class="label">Outcome</div><div class="val">' + esc(b.outcome) + '</div><div class="row">' + krLinks + '</div></div>' +
289
+ '</div>' +
290
+ (b.body ? '<div class="prose">' + prose(b.body) + '</div>' : '') +
291
+ '<h4>Reviews</h4>' + reviews +
292
+ (LIVE ? reviewForm(b) : '') +
293
+ '</article>';
294
+ }
295
+
296
+ // The bet card's notes: "## " lines become headings, the rest stays plain escaped text.
297
+ function prose(text) {
298
+ // Headings are blocks, so the line breaks either side of them would show as blank lines.
299
+ return text.trim().split('\n').map(function (l) {
300
+ return /^#{2,3} /.test(l) ? '<h4>' + esc(l.replace(/^#+ /, '')) + '</h4>' : esc(l);
301
+ }).join('\n').replace(/\n*(<h4>)/g, '$1').replace(/(<\/h4>)\n*/g, '$1');
302
+ }
303
+
304
+ function reviewForm(b) {
305
+ return '<div class="panel form" data-form="review" data-id="' + esc(b.id) + '" data-hash="' + esc(b.hash) + '"><h3>Review</h3>' +
306
+ '<div class="fields"><label>Decision<select name="decision"><option>continue</option><option>change</option><option>stop</option><option>complete</option></select></label>' +
307
+ '<label>Outcome, when completing<select name="outcome"><option value="">-</option><option value="won">won</option><option value="lost">lost</option></select></label></div>' +
308
+ '<label>Reason<textarea name="reason" rows="2"></textarea></label>' +
309
+ '<div class="actions"><button class="primary" data-act="review">Save review</button><span class="muted">Writes ' + esc(b.file) + '</span></div></div>';
310
+ }
311
+
312
+ function timelineView() {
313
+ if (!D.bets.length) return '<h1>Timeline</h1><p class="muted">No bets to plot yet.</p>';
314
+ var rows = D.bets.map(function (b) {
315
+ var s = b.starts || (b.reviews.length ? b.reviews[0].date : b.review_on);
316
+ var e = b.deadline || b.review_on;
317
+ if (ts(e) < ts(s)) e = s;
318
+ return { b: b, s: s, e: e, group: b.team || b.owner };
319
+ });
320
+ var lo = ts(TODAY), hi = ts(TODAY);
321
+ rows.forEach(function (r) { lo = Math.min(lo, ts(r.s)); hi = Math.max(hi, ts(r.e)); });
322
+ lo -= 7 * DAY; hi += 14 * DAY;
323
+ var pct = function (ms) { return ((ms - lo) / (hi - lo) * 100).toFixed(2); };
324
+ var axis = '';
325
+ var first = new Date(lo);
326
+ for (var i = 0; i < 60; i++) {
327
+ var m = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + i, 1);
328
+ if (m > hi) break;
329
+ if (m >= lo) axis += '<span style="left:' + pct(m) + '%">' + MONTHS[new Date(m).getUTCMonth()] + ' ' + new Date(m).getUTCFullYear() + '</span>';
330
+ }
331
+ var todayPct = pct(ts(TODAY));
332
+ var groups = {}, order = [];
333
+ rows.forEach(function (r) {
334
+ if (!groups[r.group]) { groups[r.group] = []; order.push(r.group); }
335
+ groups[r.group].push(r);
336
+ });
337
+ var lanesHtml = order.map(function (g) {
338
+ var ends = [];
339
+ var placed = groups[g].slice().sort(function (a, b) { return ts(a.s) - ts(b.s); }).map(function (r) {
340
+ var lane = 0;
341
+ while (lane < ends.length && ends[lane] > ts(r.s)) lane++;
342
+ ends[lane] = ts(r.e);
343
+ return { r: r, lane: lane };
344
+ });
345
+ var out = '';
346
+ for (var n = 0; n < ends.length; n++) {
347
+ var bars = placed.filter(function (p) { return p.lane === n; }).map(function (p) {
348
+ var r = p.r;
349
+ var left = pct(ts(r.s));
350
+ var width = Math.max(1.5, (ts(r.e) - ts(r.s)) / (hi - lo) * 100);
351
+ return '<a class="tbar ' + barClass(r.b.outcome) + '" href="#/bets" style="left:' + left + '%;width:' + width.toFixed(2) + '%" title="' + esc(r.b.id + ': ' + r.b.hypothesis + ' (' + r.s + ' to ' + r.e + ')') + '">' + esc(r.b.id) + '</a>';
352
+ }).join('');
353
+ out += '<div class="lane"><div class="tlabel">' + (n === 0 ? esc(g) : '') + '</div><div class="track"><div class="today" style="left:' + todayPct + '%"></div>' + bars + '</div></div>';
354
+ }
355
+ return out;
356
+ }).join('');
357
+ return '<h1>Timeline</h1><p class="muted">One row per team, or owner. Bars run from the start to the deadline, or to the review date. The line is today.</p>' +
358
+ '<div class="panel timeline"><div class="axis">' + axis + '</div>' + lanesHtml + '</div>';
359
+ }
360
+ `;
361
+ // Live-only code: saving, reloading after a save, and the toast. Absent from the export.
362
+ const SAVE_JS = String.raw `
363
+ var toastEl = document.getElementById('toast');
364
+ function toast(text, bad) {
365
+ toastEl.textContent = text;
366
+ toastEl.className = 'toast show' + (bad ? ' bad' : '');
367
+ clearTimeout(toast.timer);
368
+ toast.timer = setTimeout(function () { toastEl.className = 'toast'; }, 4000);
369
+ }
370
+ function reload() {
371
+ return fetch('/api/data').then(function (r) { return r.json(); }).then(function (d) { D = d; render(); });
372
+ }
373
+ function save(kind, payload) {
374
+ var path = { checkin: '/api/checkin', bet: '/api/bet', review: '/api/review' }[kind];
375
+ return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nikos-Token': TOKEN }, body: JSON.stringify(payload) })
376
+ .then(function (r) {
377
+ return r.json().catch(function () { return {}; }).then(function (body) { return { ok: r.ok, status: r.status, body: body }; });
378
+ })
379
+ .then(function (res) {
380
+ if (res.ok) { toast('Wrote ' + res.body.wrote); return reload(); }
381
+ toast(res.body.error || 'Save failed', true);
382
+ if (res.status === 409) return reload();
383
+ })
384
+ .catch(function (err) { toast('Could not save: ' + err.message, true); });
385
+ }
386
+ main.addEventListener('click', function (e) {
387
+ var btn = e.target.closest ? e.target.closest('[data-act]') : null;
388
+ if (!btn) return;
389
+ var form = btn.closest('[data-form]');
390
+ var val = function (name) { var el = form.querySelector('[name="' + name + '"]'); return el ? el.value : ''; };
391
+ var id = form.getAttribute('data-id');
392
+ var expect = form.getAttribute('data-hash');
393
+ if (btn.getAttribute('data-act') === 'checkin') {
394
+ if (val('value').trim() === '' || !isFinite(Number(val('value')))) return toast('Enter a number for the value', true);
395
+ save('checkin', { id: id, expect: expect, value: Number(val('value')), evidence: val('evidence'), note: val('note') });
396
+ } else if (btn.getAttribute('data-act') === 'review') {
397
+ if (!val('reason').trim()) return toast('A review needs a reason', true);
398
+ save('review', { id: id, expect: expect, decision: val('decision'), reason: val('reason').trim(), outcome: val('outcome') || undefined });
399
+ }
400
+ });
401
+ `;
402
+ /** The whole dashboard as one HTML page. Read-only renders carry no save code and no token. */
403
+ export function renderDashboard({ data, token, readOnly }) {
404
+ const live = !readOnly;
405
+ 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>`;
407
+ // The link out appears only in the served dashboard: an export is a closed file with no outside links.
408
+ const links = live
409
+ ? `<div class="links"><a href="https://www.nikovijay.com/connect?from=nikos-planning" target="_blank" rel="noopener">Made by Niko Vijay</a></div>\n`
410
+ : "";
411
+ const extra = live
412
+ ? `<h4>Story map</h4><a href="/map">Open the story map</a>` +
413
+ (data.views.length
414
+ ? `<h4>Custom views</h4>${data.views.map((v) => `<a href="/views/${esc(v)}">${esc(v)}</a>`).join("")}`
415
+ : "")
416
+ : "";
417
+ const script = [
418
+ `var LIVE = ${live};`,
419
+ live ? `var TOKEN = ${JSON.stringify(token ?? "")};` : "",
420
+ VIEWS_JS,
421
+ live ? SAVE_JS : "",
422
+ "render();",
423
+ ].join("\n");
424
+ return `<!doctype html>
425
+ <html lang="en">
426
+ <head>
427
+ <meta charset="utf-8">
428
+ <meta name="viewport" content="width=device-width, initial-scale=1">
429
+ <title>${esc(data.project)} · planning</title>
430
+ <style>${CSS}</style>
431
+ </head>
432
+ <body>
433
+ <div class="shell">
434
+ <aside class="side">
435
+ <div class="brand">nikOS planning</div>
436
+ <div class="project">${esc(data.project)}</div>
437
+ <nav>${nav}${extra}</nav>
438
+ ${links}</aside>
439
+ <div class="content">
440
+ <div class="mode">${live ? "Saves write to planning/ in this repo, and nothing else." : "Snapshot, read only. The plans are as they were when this file was made."}</div>
441
+ <main id="main"></main>
442
+ </div>
443
+ </div>
444
+ <div id="toast" class="toast" role="status"></div>
445
+ <script type="application/json" id="nikos-data">${json}</script>
446
+ <script>
447
+ ${script}
448
+ </script>
449
+ </body>
450
+ </html>
451
+ `;
452
+ }
@@ -0,0 +1,175 @@
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>/
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.
5
+ import { spawnSync } from "node:child_process";
6
+ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ import { parse, stringify } from "yaml";
9
+ import { CROSS_REF } from "../lib/bets.js";
10
+ import { parseFrontMatter } from "../lib/frontmatter.js";
11
+ import { walk } from "../lib/project.js";
12
+ 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 function upstreamEntries(root) {
16
+ const p = paths(root).upstream;
17
+ if (!existsSync(p))
18
+ return [];
19
+ const list = parse(readFileSync(p, "utf8")) ?? [];
20
+ if (!Array.isArray(list))
21
+ 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
+ });
27
+ }
28
+ export function readLock(root) {
29
+ const p = paths(root).lock;
30
+ return existsSync(p) ? (parse(readFileSync(p, "utf8")) ?? []) : [];
31
+ }
32
+ const cacheDir = (root, repo) => join(paths(root).cache, ...repo.split("/"));
33
+ /** Make sure .nikos/ is gitignored, so upstream copies are never committed. */
34
+ function ignoreCache(root) {
35
+ const p = join(root, ".gitignore");
36
+ const text = existsSync(p) ? readFileSync(p, "utf8") : "";
37
+ if (!/^\/?\.nikos\/?$/m.test(text))
38
+ appendFileSync(p, `${text && !text.endsWith("\n") ? "\n" : ""}# nikos planning: read-only copies of upstream plans\n.nikos/\n`);
39
+ }
40
+ /** Fetch every upstream plan read-only. Unreachable ones are reported, never fatal. */
41
+ export function sync(root) {
42
+ ignoreCache(root);
43
+ const report = { synced: [], unresolved: [] };
44
+ const previous = readLock(root);
45
+ for (const e of upstreamEntries(root)) {
46
+ const dir = cacheDir(root, e.repo);
47
+ rmSync(dir, { recursive: true, force: true });
48
+ const clone = git(["clone", "--quiet", "--depth", "1", "--branch", e.ref, e.url, dir]);
49
+ if (clone.status !== 0) {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ report.unresolved.push({ repo: e.repo, reason: (clone.stderr || "clone failed").trim().split("\n").pop() });
52
+ const kept = previous.find((l) => l.repo === e.repo);
53
+ if (kept)
54
+ report.synced.push(kept); // keep the last good lock entry; its copy is gone, so refs show as unresolved
55
+ continue;
56
+ }
57
+ const commit = git(["rev-parse", "HEAD"], dir).stdout.trim();
58
+ 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() });
60
+ }
61
+ writePlanning(root, paths(root).lock, `# Written by nikos planning sync: the upstream commit each plan was read at.\n${stringify(report.synced)}`);
62
+ return report;
63
+ }
64
+ /** Compare each locked commit with the upstream's current one. */
65
+ export function checkStale(root) {
66
+ const entries = upstreamEntries(root);
67
+ const out = { stale: [], unresolved: [] };
68
+ for (const l of readLock(root)) {
69
+ const e = entries.find((x) => x.repo === l.repo);
70
+ if (!e)
71
+ continue;
72
+ const r = git(["ls-remote", e.url, e.ref]);
73
+ const current = r.status === 0 ? r.stdout.split(/\s+/)[0] : "";
74
+ if (!current)
75
+ out.unresolved.push({ repo: l.repo, reason: (r.stderr || "not reachable").trim().split("\n").pop() });
76
+ else if (current !== l.commit)
77
+ out.stale.push({ repo: l.repo, locked: l.commit, current });
78
+ }
79
+ return out;
80
+ }
81
+ /** The read-only copies on disk: each upstream's key results and bets. */
82
+ export function loadUpstream(root) {
83
+ 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);
88
+ const out = [];
89
+ for (const repo of repos) {
90
+ const root2 = cacheDir(root, repo);
91
+ if (!existsSync(join(root2, "planning")))
92
+ continue;
93
+ const krs = krIds(root2).flatMap((id) => {
94
+ try {
95
+ return [loadKr(root2, id).data];
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ });
101
+ const bets = betIds(root2).flatMap((id) => {
102
+ try {
103
+ return [loadBet(root2, id).meta];
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ });
109
+ out.push({ repo, commit: lock.find((l) => l.repo === repo)?.commit, krs, bets });
110
+ }
111
+ return out;
112
+ }
113
+ /** Find owner/repo#id among the synced copies. */
114
+ export function resolveRef(root, ref) {
115
+ if (!CROSS_REF.test(ref))
116
+ return undefined;
117
+ const [repo, id] = ref.split("#");
118
+ const up = loadUpstream(root).find((u) => u.repo === repo);
119
+ const kr = up?.krs.find((k) => k.id === id);
120
+ if (kr)
121
+ return { repo, kind: "kr", data: kr };
122
+ const bet = up?.bets.find((b) => b.id === id);
123
+ return bet ? { repo, kind: "bet", data: bet } : undefined;
124
+ }
125
+ /** Every owner/repo#id this repo's planning files and story map name, with whether it resolves. */
126
+ export function crossRefs(root) {
127
+ const refs = [];
128
+ for (const id of krIds(root)) {
129
+ const k = loadKr(root, id);
130
+ if (k.data?.aligns_to)
131
+ refs.push({ ref: k.data.aligns_to, file: k.file });
132
+ }
133
+ for (const p of walk(paths(root).bets, ".md")) {
134
+ const meta = parseFrontMatter(readFileSync(p, "utf8"))?.meta;
135
+ for (const r of [meta?.krs?.primary, ...(meta?.krs?.also ?? [])])
136
+ if (typeof r === "string" && r.includes("#"))
137
+ refs.push({ ref: r, file: `planning/bets/${basename(p)}` });
138
+ }
139
+ const mapPath = join(paths(root).intent, "storymap.yaml");
140
+ if (existsSync(mapPath)) {
141
+ const map = parse(readFileSync(mapPath, "utf8"));
142
+ for (const r of map?.releases ?? [])
143
+ for (const b of r?.bets ?? [])
144
+ if (CROSS_REF.test(b))
145
+ refs.push({ ref: b, file: "intent/storymap.yaml" });
146
+ for (const list of Object.values(map?.story_bets ?? {}))
147
+ for (const b of list)
148
+ if (CROSS_REF.test(b))
149
+ refs.push({ ref: b, file: "intent/storymap.yaml" });
150
+ }
151
+ return refs.map((r) => ({ ...r, resolved: resolveRef(root, r.ref) !== undefined }));
152
+ }
153
+ export async function runSync(root, opts = {}) {
154
+ if (!upstreamEntries(root).length) {
155
+ console.log("No upstream plans: add them to planning/upstream.yaml as `- repo: owner/repo` (ref defaults to main).");
156
+ return 0;
157
+ }
158
+ if (opts.check) {
159
+ const r = checkStale(root);
160
+ for (const s of r.stale)
161
+ console.log(`! ${s.repo} is stale: locked at ${s.locked.slice(0, 7)}, upstream is at ${s.current.slice(0, 7)} — run \`nikos planning sync\``);
162
+ for (const u of r.unresolved)
163
+ console.log(`? ${u.repo} is unresolved: ${u.reason}`);
164
+ if (!r.stale.length && !r.unresolved.length)
165
+ console.log("✓ every upstream copy is current");
166
+ return 0;
167
+ }
168
+ const r = sync(root);
169
+ for (const s of r.synced.filter((s) => !r.unresolved.some((u) => u.repo === s.repo)))
170
+ console.log(`✓ ${s.repo} @ ${s.commit.slice(0, 7)} → .nikos/upstream/${s.repo}/ (read-only copy)`);
171
+ for (const u of r.unresolved)
172
+ console.log(`? ${u.repo} is unresolved: ${u.reason}. Planning carries on without it.`);
173
+ console.log("✓ planning/upstream.lock updated");
174
+ return 0;
175
+ }