syndes 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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Element construction and value formatting.
3
+ *
4
+ * Everything builds nodes, never HTML strings. Ledger content is prompts, file
5
+ * paths and shell commands — string templating would be one forgotten escape
6
+ * away from putting whatever a user typed into their editor into the DOM.
7
+ */
8
+
9
+ export function h(spec, props = {}, children = []) {
10
+ const [tag, ...classes] = spec.split('.');
11
+ const node = document.createElement(tag || 'div');
12
+ if (classes.length) node.className = classes.join(' ');
13
+
14
+ for (const [key, value] of Object.entries(props)) {
15
+ if (value === null || value === undefined || value === false) continue;
16
+ if (key === 'class') node.className = `${node.className} ${value}`.trim();
17
+ else if (key === 'style') node.style.cssText = value;
18
+ else if (key === 'text') node.textContent = String(value);
19
+ else if (key.startsWith('on')) node.addEventListener(key.slice(2).toLowerCase(), value);
20
+ else node.setAttribute(key, value === true ? '' : String(value));
21
+ }
22
+ for (const child of [].concat(children)) {
23
+ if (child === null || child === undefined || child === false) continue;
24
+ node.appendChild(typeof child === 'object' ? child : document.createTextNode(String(child)));
25
+ }
26
+ return node;
27
+ }
28
+
29
+ /**
30
+ * Inline SVG icons.
31
+ *
32
+ * Drawn rather than typed. The first pass used text glyphs (⟳ ⇩ ✓ ⚙), which
33
+ * render at whatever weight and baseline the system font decides — so they sat
34
+ * off-centre in their circles and changed shape between machines.
35
+ *
36
+ * 24x24 grid, stroke-only, currentColor, so one icon works on a dark pill and
37
+ * on a lime one without needing a second asset.
38
+ */
39
+ const ICONS = {
40
+ refresh: ['M20.5 12a8.5 8.5 0 1 1-2.6-6.1', 'M20.5 4.5v5h-5'],
41
+ download: ['M12 3.5v11', 'M7.5 10.5 12 15l4.5-4.5', 'M4.5 19.5h15'],
42
+ verify: ['M12 3 19 6v6c0 4.2-2.9 7.7-7 9-4.1-1.3-7-4.8-7-9V6l7-3Z', 'M9 12l2.2 2.2L15.5 10'],
43
+ settings: [
44
+ 'M4 8h9', 'M17 8h3', 'M4 16h4', 'M12 16h8',
45
+ 'M15 8a2 2 0 1 0 4 0 2 2 0 0 0-4 0', 'M8 16a2 2 0 1 0 4 0 2 2 0 0 0-4 0',
46
+ ],
47
+ chevron: ['M6.5 9.5 12 15l5.5-5.5'],
48
+ tick: ['M5 12.5 9.5 17 19 7.5'],
49
+ };
50
+
51
+ export function icon(name, { size = 20 } = {}) {
52
+ const NS = 'http://www.w3.org/2000/svg';
53
+ const svg = document.createElementNS(NS, 'svg');
54
+ for (const [key, value] of Object.entries({
55
+ viewBox: '0 0 24 24', width: size, height: size, fill: 'none',
56
+ stroke: 'currentColor', 'stroke-width': '1.7',
57
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
58
+ })) svg.setAttribute(key, String(value));
59
+
60
+ for (const d of ICONS[name] ?? []) {
61
+ const path = document.createElementNS(NS, 'path');
62
+ path.setAttribute('d', d);
63
+ svg.appendChild(path);
64
+ }
65
+ return svg;
66
+ }
67
+
68
+ /**
69
+ * Replace children, dropping nulls.
70
+ *
71
+ * Element.replaceChildren() turns a non-Node argument into a TEXT NODE, so a
72
+ * conditional child written `cond ? card() : null` renders the literal word
73
+ * "null". Views use this and never replaceChildren directly.
74
+ */
75
+ /**
76
+ * A dropdown.
77
+ *
78
+ * Not a native <select>. A native one on macOS opens the system menu — blue
79
+ * highlight, system font, system corners — which lands in the middle of a black
80
+ * capsule interface looking like a different application. It also gives no
81
+ * affordance that it can be opened at all, because a bare word in a pill reads
82
+ * as a label.
83
+ *
84
+ * The menu is appended to document.body and positioned fixed, so it is never
85
+ * clipped by the card's overflow or the shell's. That is the whole reason it is
86
+ * not simply absolutely positioned inside the trigger.
87
+ */
88
+ export function dropdown({ value, options, onChange, label = null, onCard = false }) {
89
+ const current = () => options.find((option) => option.id === value) ?? options[0];
90
+
91
+ const valueNode = h('span.dropdown__value', { text: current()?.label ?? '' });
92
+ const trigger = h(`button.dropdown${onCard ? '.dropdown--on-card' : ''}`, {
93
+ type: 'button',
94
+ 'aria-haspopup': 'listbox',
95
+ 'aria-expanded': 'false',
96
+ }, [
97
+ label ? h('span.dropdown__label', { text: label }) : null,
98
+ valueNode,
99
+ icon('chevron', { size: 15 }),
100
+ ]);
101
+
102
+ let menu = null;
103
+
104
+ const close = () => {
105
+ if (!menu) return;
106
+ menu.remove();
107
+ menu = null;
108
+ trigger.setAttribute('aria-expanded', 'false');
109
+ document.removeEventListener('pointerdown', onOutside, true);
110
+ document.removeEventListener('keydown', onKey, true);
111
+ window.removeEventListener('resize', close);
112
+ // A fixed menu would visibly detach from its trigger once anything scrolls.
113
+ window.removeEventListener('scroll', close, true);
114
+ };
115
+
116
+ const onOutside = (event) => {
117
+ if (!menu?.contains(event.target) && !trigger.contains(event.target)) close();
118
+ };
119
+
120
+ const onKey = (event) => {
121
+ if (!menu) return;
122
+ const items = [...menu.querySelectorAll('[role="option"]')];
123
+ const index = items.indexOf(document.activeElement);
124
+
125
+ if (event.key === 'Escape') { event.preventDefault(); close(); trigger.focus(); }
126
+ else if (event.key === 'ArrowDown') { event.preventDefault(); items[Math.min(index + 1, items.length - 1)]?.focus(); }
127
+ else if (event.key === 'ArrowUp') { event.preventDefault(); (index <= 0 ? items[0] : items[index - 1])?.focus(); }
128
+ else if (event.key === 'Home') { event.preventDefault(); items[0]?.focus(); }
129
+ else if (event.key === 'End') { event.preventDefault(); items[items.length - 1]?.focus(); }
130
+ };
131
+
132
+ const open = () => {
133
+ if (menu) return close();
134
+
135
+ menu = h('div.dropdown__menu', { role: 'listbox', 'aria-label': label ?? 'Options' },
136
+ options.map((option) => h('button.dropdown__item', {
137
+ type: 'button',
138
+ role: 'option',
139
+ 'aria-selected': String(option.id === value),
140
+ onclick: () => { close(); if (option.id !== value) onChange(option.id); },
141
+ }, [
142
+ h('span.dropdown__check', {}, [option.id === value ? icon('tick', { size: 13 }) : null]),
143
+ h('span', {}, [
144
+ h('span.dropdown__item-name', { text: option.label }),
145
+ option.hint ? h('span.dropdown__item-hint', { text: option.hint }) : null,
146
+ ]),
147
+ ])));
148
+
149
+ document.body.appendChild(menu);
150
+
151
+ const rect = trigger.getBoundingClientRect();
152
+ menu.style.minWidth = `${Math.max(rect.width, 210)}px`;
153
+
154
+ const { offsetWidth: width, offsetHeight: height } = menu;
155
+ const MARGIN = 12;
156
+
157
+ // Flip above when there is not room below, so the last option is never cut off.
158
+ const below = window.innerHeight - rect.bottom > height + MARGIN;
159
+ menu.style.top = below ? `${rect.bottom + 8}px` : `${Math.max(MARGIN, rect.top - height - 8)}px`;
160
+
161
+ // The menu is usually wider than its trigger, so one of its edges has to
162
+ // line up with the button and the other has to overhang. Anchor the edge
163
+ // the trigger is NEAREST: a control on the right side of the screen gets a
164
+ // menu that hangs left, and vice versa.
165
+ //
166
+ // The previous rule only right-anchored when left-anchoring would have
167
+ // overflowed, so a trigger on the right with room to spare still got a menu
168
+ // trailing off to its right — attached to nothing.
169
+ const nearRightEdge = rect.left + rect.width / 2 > window.innerWidth / 2;
170
+ let left = nearRightEdge ? rect.right - width : rect.left;
171
+
172
+ // If the preferred edge does not fit, use the other one.
173
+ if (left < MARGIN) left = rect.left;
174
+ if (left + width + MARGIN > window.innerWidth) left = rect.right - width;
175
+
176
+ menu.style.left = `${Math.max(MARGIN, Math.min(left, window.innerWidth - width - MARGIN))}px`;
177
+
178
+ trigger.setAttribute('aria-expanded', 'true');
179
+ menu.querySelector('[aria-selected="true"]')?.focus();
180
+
181
+ document.addEventListener('pointerdown', onOutside, true);
182
+ document.addEventListener('keydown', onKey, true);
183
+ window.addEventListener('resize', close);
184
+ window.addEventListener('scroll', close, true);
185
+ };
186
+
187
+ trigger.addEventListener('click', open);
188
+ return trigger;
189
+ }
190
+
191
+ export function replace(node, ...children) {
192
+ node.replaceChildren(...children.flat().filter(Boolean));
193
+ return node;
194
+ }
195
+
196
+ export function mount(node, children) {
197
+ return replace(node, children);
198
+ }
199
+
200
+ // ── Formatting ─────────────────────────────────────────────────────────────
201
+ // Mirrors src/term.mjs, so the terminal report and this page never disagree.
202
+ // An em dash means "not measured". It never means zero.
203
+
204
+ export const DASH = '—';
205
+
206
+ export function compact(value) {
207
+ if (value === null || value === undefined || Number.isNaN(value)) return DASH;
208
+ const abs = Math.abs(value);
209
+ if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
210
+ if (abs >= 10_000) return `${Math.round(value / 1000)}k`;
211
+ if (abs >= 1000) return `${(value / 1000).toFixed(1)}k`;
212
+ return String(Math.round(value));
213
+ }
214
+
215
+ export function usd(value) {
216
+ if (value === null || value === undefined) return DASH;
217
+ return value >= 1000 ? `$${Math.round(value).toLocaleString()}` : `$${value.toFixed(2)}`;
218
+ }
219
+
220
+ export function duration(ms) {
221
+ if (!ms || ms < 0) return '0m';
222
+ const minutes = Math.round(ms / 60_000);
223
+ if (minutes < 60) return `${minutes}m`;
224
+ const hours = Math.floor(minutes / 60);
225
+ const rest = minutes % 60;
226
+ return rest ? `${hours}h ${rest}m` : `${hours}h`;
227
+ }
228
+
229
+ export function percent(fraction, digits = 0) {
230
+ if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return DASH;
231
+ return `${(fraction * 100).toFixed(digits)}%`;
232
+ }
233
+
234
+ export function bytes(value) {
235
+ const units = ['B', 'KB', 'MB', 'GB'];
236
+ let size = value ?? 0;
237
+ let unit = 0;
238
+ while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
239
+ return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
240
+ }
241
+
242
+ export const when = (ts) => (ts ? new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : DASH);
243
+ export const clock = (ts) => new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
244
+ export const dayName = (iso) => new Date(`${iso}T12:00`).toLocaleDateString(undefined, { weekday: 'short' });
245
+
246
+ // ── Components ─────────────────────────────────────────────────────────────
247
+
248
+ export function card(title, body, { note = null, actions = null, className = '' } = {}) {
249
+ return h(`section.card${className ? `.${className}` : ''}`, {}, [
250
+ title ? h('div.card__head', {}, [
251
+ h('h2.card__title', { text: title }),
252
+ note ? h('span.card__note', { text: note }) : null,
253
+ actions ? h('div.card__actions', {}, [actions]) : null,
254
+ ]) : null,
255
+ ...[].concat(body).filter(Boolean),
256
+ ]);
257
+ }
258
+
259
+ /** A big number with its label. The unit rides small beside the value. */
260
+ export function figure(label, value, { unit = null, small = false, trend = null } = {}) {
261
+ return h('div.figure', {}, [
262
+ h(`div.figure__value${small ? '.figure__value--sm' : ''}`, {}, [
263
+ String(value),
264
+ unit ? h('span.figure__unit', { text: ` ${unit}` }) : null,
265
+ ]),
266
+ h('div.figure__label', { text: label }),
267
+ trend,
268
+ ]);
269
+ }
270
+
271
+ /**
272
+ * The reference's ▲/▼ mark.
273
+ *
274
+ * The triangle carries the direction and the colour reinforces the judgement,
275
+ * so this reads correctly in a screenshot, in greyscale, and for anyone who
276
+ * cannot separate lime from orange.
277
+ */
278
+ export function trend(now, before, { format = compact, goodDirection = 'up', suffix = 'vs last period' } = {}) {
279
+ if (before === null || before === undefined || !Number.isFinite(before) || before === 0) {
280
+ return h('div.trend.trend--flat', {}, [h('span.trend__text', { text: 'no prior period' })]);
281
+ }
282
+ const change = now - before;
283
+ if (!change) return h('div.trend.trend--flat', {}, [h('span.trend__mark', { text: '■' }), h('span.trend__text', { text: `unchanged ${suffix}` })]);
284
+
285
+ const rising = change > 0;
286
+ const good = goodDirection === 'up' ? rising : !rising;
287
+ return h(`div.trend.trend--${good ? 'up' : 'down'}`, {}, [
288
+ h('span.trend__mark', { text: rising ? '▲' : '▼', 'aria-hidden': 'true' }),
289
+ h('span.trend__text', { text: `${format(Math.abs(change))} ${suffix}` }),
290
+ ]);
291
+ }
292
+
293
+ export function chip(text, tone = null) {
294
+ return h(`span.chip${tone ? `.chip--${tone}` : ''}`, { text });
295
+ }
296
+
297
+ export function dot(tone) {
298
+ return h(`span.dot.dot--${tone}`);
299
+ }
300
+
301
+ export function meter(fraction, tone = 'lime') {
302
+ return h('div.meter', {}, [
303
+ h(`div.meter__fill${tone === 'orange' ? '.meter__fill--orange' : tone === 'white' ? '.meter__fill--white' : ''}`, {
304
+ style: `width:${Math.max(0, Math.min(1, fraction ?? 0)) * 100}%`,
305
+ }),
306
+ ]);
307
+ }
308
+
309
+ export function legend(items, total = null) {
310
+ return h('div.legend', {}, [
311
+ ...items.map((item) => h('span.legend__item', {}, [dot(item.tone), h('span', { text: item.label })])),
312
+ total !== null ? h('span.legend__total', {}, ['Total: ', h('b', { text: total })]) : null,
313
+ ]);
314
+ }
315
+
316
+ export function table(headers, rows, { align = [], onRow = null } = {}) {
317
+ return h('div.scroll-x', {}, [
318
+ h('table.table', {}, [
319
+ h('thead', {}, [h('tr', {}, headers.map((label, i) =>
320
+ h(`th${align[i] === 'right' ? '.r' : ''}`, { text: label })))]),
321
+ h('tbody', {}, rows.map((cells, index) => h('tr', onRow ? { onclick: () => onRow(index) } : {},
322
+ cells.map((cell, i) => h(`td${align[i] === 'right' ? '.r' : ''}`, {},
323
+ [typeof cell === 'object' && cell !== null ? cell : String(cell ?? DASH)]))))),
324
+ ]),
325
+ ]);
326
+ }
327
+
328
+ export function empty(message) {
329
+ return h('div.empty', { text: message });
330
+ }
331
+
332
+ /**
333
+ * A modal dialog.
334
+ *
335
+ * Replaces window.prompt / alert / confirm. Those render browser chrome — the
336
+ * origin printed as a title, system buttons, system corners — which lands in
337
+ * the middle of this interface looking like the page has been hijacked. They
338
+ * also block the event loop, so they cannot be styled or animated at all.
339
+ *
340
+ * @returns {Promise<string|boolean|null>} the input value, true for a plain
341
+ * confirm, or null when dismissed.
342
+ */
343
+ export function modal({
344
+ title,
345
+ note = null,
346
+ input = null, // { placeholder, type, inputmode, maxlength, validate }
347
+ confirmLabel = 'Confirm',
348
+ cancelLabel = 'Cancel',
349
+ danger = false,
350
+ }) {
351
+ return new Promise((resolve) => {
352
+ const error = h('div.modal__error', { role: 'alert' });
353
+ const field = input
354
+ ? h('input.modal__input', {
355
+ type: input.type ?? 'text',
356
+ inputmode: input.inputmode ?? null,
357
+ maxlength: input.maxlength ?? null,
358
+ placeholder: input.placeholder ?? '',
359
+ autocomplete: 'off',
360
+ })
361
+ : null;
362
+
363
+ const finish = (value) => { cleanup(); resolve(value); };
364
+
365
+ const submit = () => {
366
+ if (!field) return finish(true);
367
+ const problem = input.validate?.(field.value);
368
+ if (problem) {
369
+ error.textContent = problem;
370
+ field.focus();
371
+ return undefined;
372
+ }
373
+ return finish(field.value);
374
+ };
375
+
376
+ const panel = h('div.modal', { role: 'dialog', 'aria-modal': 'true', 'aria-label': title }, [
377
+ h('div.modal__title', { text: title }),
378
+ note ? h('div.modal__note', { text: note }) : null,
379
+ field,
380
+ field ? error : null,
381
+ h('div.modal__actions', {}, [
382
+ h('button.btn.btn--ghost', { type: 'button', text: cancelLabel, onclick: () => finish(null) }),
383
+ h(`button.btn${danger ? '' : '.btn--lime'}`, { type: 'button', text: confirmLabel, onclick: submit }),
384
+ ]),
385
+ ]);
386
+
387
+ const scrim = h('div.scrim', { onclick: () => finish(null) });
388
+
389
+ const onKey = (event) => {
390
+ if (event.key === 'Escape') { event.preventDefault(); finish(null); }
391
+ else if (event.key === 'Enter' && panel.contains(document.activeElement)) { event.preventDefault(); submit(); }
392
+ };
393
+
394
+ function cleanup() {
395
+ document.removeEventListener('keydown', onKey, true);
396
+ scrim.remove();
397
+ panel.remove();
398
+ }
399
+
400
+ document.addEventListener('keydown', onKey, true);
401
+ document.body.append(scrim, panel);
402
+ (field ?? panel.querySelector('.btn--lime') ?? panel).focus();
403
+ });
404
+ }
405
+
406
+ /** A right-side drawer. Returns a close function so callers can chain onto it. */
407
+ export function drawer(title, subtitle, body) {
408
+ const scrim = h('div.scrim', { onclick: close });
409
+ const panel = h('aside.drawer', { role: 'dialog', 'aria-label': title }, [
410
+ h('div.drawer__head', {}, [
411
+ h('div', {}, [
412
+ h('div.drawer__title', { text: title }),
413
+ subtitle ? h('div', { style: 'font-size:12px;color:var(--ink-3);margin-top:3px', text: subtitle }) : null,
414
+ ]),
415
+ h('button.btn.btn--ghost.btn--sm', { text: 'Close', style: 'margin-left:auto', onclick: close }),
416
+ ]),
417
+ h('div.drawer__body', {}, [].concat(body).filter(Boolean)),
418
+ ]);
419
+
420
+ function close() {
421
+ scrim.remove();
422
+ panel.remove();
423
+ document.removeEventListener('keydown', onKey);
424
+ }
425
+ function onKey(event) { if (event.key === 'Escape') close(); }
426
+
427
+ document.addEventListener('keydown', onKey);
428
+ document.body.append(scrim, panel);
429
+ return close;
430
+ }
431
+
432
+ export function kv(key, value) {
433
+ return h('div.kv__row', {}, [h('span.kv__k', { text: key }), h('span.kv__v', { text: value })]);
434
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Habits — the four numbers you can actually move, and what is currently wrong.
3
+ *
4
+ * Every gauge prints its raw counts under the percentage. A rate with no
5
+ * denominator is unfalsifiable, and "86%" means nothing until you know it is
6
+ * 43 of 50.
7
+ *
8
+ * Colour is the verdict: lime once a habit is where it should be, orange while
9
+ * it is not. Nothing here is coloured for looks.
10
+ */
11
+
12
+ import { api } from '../api.js';
13
+ import { h, card, figure, chip, dot, meter, legend, empty, table, compact, percent, duration } from '../ui.js';
14
+ import { capsules, ranked } from '../charts.js';
15
+
16
+ /** The bar each habit has to clear before it stops being a problem. */
17
+ const TARGETS = { readBeforeEdit: 0.7, searchDiscipline: 0.6, verify: 0.5, batching: 1.5 };
18
+
19
+ export async function render({ range }) {
20
+ const [{ tools, tokens, rework, friction }, practices] = await Promise.all([
21
+ api.tools(range),
22
+ api.practices(),
23
+ ]);
24
+
25
+ return h('div.content--fit', {
26
+ style: 'display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:var(--s4);min-height:0',
27
+ }, [
28
+ gauges(tools, tokens, rework),
29
+ h('div.grid.g-2', {}, [toolMix(tools), frictionCard(friction, rework)]),
30
+ h('div.fill', {}, [findings(practices)]),
31
+ ]);
32
+ }
33
+
34
+ // ── The four movable numbers ───────────────────────────────────────────────
35
+
36
+ function gauges(tools, tokens, rework) {
37
+ const items = [
38
+ {
39
+ label: 'Read before editing',
40
+ value: tools.readBeforeEditRate,
41
+ display: percent(tools.readBeforeEditRate),
42
+ fraction: tools.readBeforeEditRate,
43
+ target: TARGETS.readBeforeEdit,
44
+ detail: `${tools.readFirst} of ${tools.readFirst + tools.blindEdit} edits`,
45
+ },
46
+ {
47
+ label: 'Search tools over bash',
48
+ value: tools.searchDiscipline,
49
+ display: percent(tools.searchDiscipline),
50
+ fraction: tools.searchDiscipline,
51
+ target: TARGETS.searchDiscipline,
52
+ detail: `${tools.searchViaTool} of ${tools.searchViaBash + tools.searchViaTool} searches`,
53
+ },
54
+ {
55
+ label: 'Verified after changing',
56
+ value: rework.verifyRate,
57
+ display: percent(rework.verifyRate),
58
+ fraction: rework.verifyRate,
59
+ target: TARGETS.verify,
60
+ detail: `${rework.verifiedRuns} of ${rework.verifiedRuns + rework.unverifiedRuns} edit runs`,
61
+ },
62
+ {
63
+ label: 'Calls batched per turn',
64
+ value: tokens.callsPerTurn,
65
+ display: (tokens.callsPerTurn ?? 0).toFixed(2),
66
+ fraction: Math.min(1, (tokens.callsPerTurn ?? 0) / 3),
67
+ target: TARGETS.batching / 3,
68
+ detail: `${compact(tokens.toolUses)} calls in ${compact(tokens.turnsWithTools)} turns`,
69
+ rawTarget: TARGETS.batching,
70
+ },
71
+ ];
72
+
73
+ return h('div.grid.g-4', {}, items.map((item) => {
74
+ const measured = item.value !== null && item.value !== undefined;
75
+ const met = measured && item.value >= (item.rawTarget ?? item.target);
76
+ return card(null, [
77
+ h('div.figure', {}, [
78
+ h('div.figure__value', { text: measured ? item.display : '—' }),
79
+ h('div.figure__label', { text: item.label }),
80
+ ]),
81
+ meter(item.fraction ?? 0, !measured ? 'white' : met ? 'lime' : 'orange'),
82
+ h('div', { style: 'display:flex;align-items:center;gap:8px' }, [
83
+ measured ? chip(met ? 'on target' : 'below target', met ? 'lime' : 'orange') : chip('not measured'),
84
+ h('span', { style: 'font-size:12px;color:var(--ink-3)', text: item.detail }),
85
+ ]),
86
+ ], { className: 'card--tight' });
87
+ }));
88
+ }
89
+
90
+ // ── What was used ──────────────────────────────────────────────────────────
91
+
92
+ function toolMix(tools) {
93
+ const byTool = Object.entries(tools.byTool ?? {})
94
+ .sort((a, b) => b[1] - a[1])
95
+ .slice(0, 8)
96
+ .map(([label, value]) => ({ label, value, tone: 'white' }));
97
+
98
+ return card('Tools used', [
99
+ byTool.length ? capsules(byTool, { height: 180 }) : empty('No tool calls in this range'),
100
+ legend([{ tone: 'white', label: 'calls' }], compact(tools.calls)),
101
+ ]);
102
+ }
103
+
104
+ function frictionCard(friction, rework) {
105
+ const offenders = Object.entries(friction.byTarget ?? {})
106
+ .sort((a, b) => b[1] - a[1]).slice(0, 5)
107
+ .map(([label, value]) => ({ label, value, tone: 'orange' }));
108
+
109
+ return card('Friction', [
110
+ h('div.pair', {}, [
111
+ figure('Permission stops', friction.blocks, { small: true }),
112
+ figure('Waiting', duration(friction.blockedMs), { small: true }),
113
+ figure('Failure rate', percent(rework.errorRate, 1), { small: true }),
114
+ ]),
115
+ offenders.length ? ranked(offenders, { formatValue: (value) => `${value}×` }) : empty('Nothing blocked you'),
116
+ friction.worstOffender
117
+ ? h('div', { style: 'display:flex;gap:10px;align-items:center;padding:14px;border-radius:16px;background:var(--surface-2)' }, [
118
+ h('span', { style: 'color:var(--lime)', text: '→' }),
119
+ h('span', { style: 'font-size:12px', text: `Allowlisting "${friction.worstOffender.target}" removes ${friction.worstOffender.count} interruptions.` }),
120
+ ])
121
+ : null,
122
+ ]);
123
+ }
124
+
125
+ // ── Everything the rules found ─────────────────────────────────────────────
126
+
127
+ function findings({ findings: all }) {
128
+ const live = all.filter((finding) => !finding.muted);
129
+ const muted = all.filter((finding) => finding.muted);
130
+
131
+ const node = card('What to change', h('div.card__scroll', {}, [
132
+ ...(live.length ? live.map((finding) => row(finding, refresh)) : [empty('No rule found a pattern worth changing.')]),
133
+ ...(muted.length ? [
134
+ h('div.card__title', { style: 'margin:18px 0 4px;color:var(--ink-3)', text: `Muted · ${muted.length}` }),
135
+ ...muted.map((finding) => row(finding, refresh)),
136
+ ] : []),
137
+ ]), { note: muted.length ? 'muting hides the nudge, it does not fix the habit' : null });
138
+
139
+ async function refresh(action, rule) {
140
+ await api.practiceAction(action, rule);
141
+ const fresh = await api.practices();
142
+ node.replaceWith(findings(fresh));
143
+ }
144
+
145
+ return node;
146
+ }
147
+
148
+ function row(finding, act) {
149
+ return h(`div.finding${finding.muted ? '.finding--muted' : ''}`, {}, [
150
+ h('div.finding__head', {}, [
151
+ dot(finding.severity >= 3 ? 'orange' : finding.severity === 2 ? 'white' : 'lime'),
152
+ h('span.finding__title', { text: finding.title }),
153
+ chip(finding.pillar),
154
+ h('button.btn.btn--ghost.btn--sm', {
155
+ style: 'margin-left:auto',
156
+ text: finding.muted ? 'Unmute' : 'Mute',
157
+ onclick: () => act(finding.muted ? 'unmute' : 'mute', finding.id),
158
+ }),
159
+ ]),
160
+ h('div.finding__evidence', { text: finding.evidence }),
161
+ h('div.finding__fix', {}, [h('span', { text: finding.fix })]),
162
+ finding.action
163
+ ? h('div', { style: 'font-family:var(--mono);font-size:12px;color:var(--lime);padding-left:18px', text: finding.action })
164
+ : null,
165
+ ]);
166
+ }