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,118 @@
1
+ /** Tool mix, read-before-edit, search discipline, delegation, top targets. */
2
+
3
+ import { KIND } from '../../ledger/schema.mjs';
4
+
5
+ export const name = 'tools';
6
+ const TOP = 40;
7
+
8
+ export function create() {
9
+ return {
10
+ byFamily: new Map(), byTool: new Map(), commands: new Map(), targets: new Map(),
11
+ readFirst: 0, blindEdit: 0,
12
+ searchViaBash: 0, searchViaTool: 0,
13
+ verifications: 0, delegations: 0, skills: 0,
14
+ durations: [], failures: 0, calls: 0,
15
+ };
16
+ }
17
+
18
+ export function add(state, record) {
19
+ const data = record.data;
20
+
21
+ if (record.kind === KIND.TOOL_PRE) {
22
+ if (data.searchViaBash) state.searchViaBash += 1;
23
+ if (data.family === 'search') state.searchViaTool += 1;
24
+ if (data.verification) state.verifications += 1;
25
+ if (data.family === 'delegate') state.delegations += 1;
26
+ if (data.tool === 'Skill') state.skills += 1;
27
+ if (data.tool === 'Bash' && data.target) bump(state.commands, data.target);
28
+ return;
29
+ }
30
+
31
+ if (record.kind === KIND.TOOL_POST) {
32
+ state.calls += 1;
33
+ bump(state.byFamily, data.family ?? 'other');
34
+ bump(state.byTool, data.tool ?? 'Unknown');
35
+ if (data.target) bump(state.targets, data.target);
36
+ if (data.failed) state.failures += 1;
37
+ if (typeof data.durationMs === 'number') state.durations.push(data.durationMs);
38
+ return;
39
+ }
40
+
41
+ if (record.kind === KIND.FILE_TOUCH) {
42
+ if (data.readFirst) state.readFirst += 1;
43
+ else state.blindEdit += 1;
44
+ }
45
+ }
46
+
47
+ export function done(state) {
48
+ return {
49
+ byFamily: Object.fromEntries(state.byFamily),
50
+ byTool: Object.fromEntries(state.byTool),
51
+ commands: top(state.commands, TOP),
52
+ targets: top(state.targets, TOP),
53
+ readFirst: state.readFirst,
54
+ blindEdit: state.blindEdit,
55
+ searchViaBash: state.searchViaBash,
56
+ searchViaTool: state.searchViaTool,
57
+ verifications: state.verifications,
58
+ delegations: state.delegations,
59
+ skills: state.skills,
60
+ failures: state.failures,
61
+ calls: state.calls,
62
+ // A duration histogram, not the raw list: the list grows without bound and
63
+ // nothing downstream asks a question the buckets cannot answer.
64
+ durationBuckets: bucketize(state.durations),
65
+ };
66
+ }
67
+
68
+ export function merge(parts) {
69
+ const out = {
70
+ byFamily: {}, byTool: {}, commands: {}, targets: {},
71
+ readFirst: 0, blindEdit: 0, searchViaBash: 0, searchViaTool: 0,
72
+ verifications: 0, delegations: 0, skills: 0, failures: 0, calls: 0,
73
+ durationBuckets: {},
74
+ };
75
+
76
+ for (const part of parts) {
77
+ for (const key of ['byFamily', 'byTool', 'commands', 'targets', 'durationBuckets']) {
78
+ for (const [name_, count] of Object.entries(part[key] ?? {})) {
79
+ out[key][name_] = (out[key][name_] ?? 0) + count;
80
+ }
81
+ }
82
+ for (const key of ['readFirst', 'blindEdit', 'searchViaBash', 'searchViaTool',
83
+ 'verifications', 'delegations', 'skills', 'failures', 'calls']) {
84
+ out[key] += part[key] ?? 0;
85
+ }
86
+ }
87
+
88
+ const edits = out.readFirst + out.blindEdit;
89
+ const searches = out.searchViaBash + out.searchViaTool;
90
+ return {
91
+ ...out,
92
+ commands: top(new Map(Object.entries(out.commands)), TOP),
93
+ targets: top(new Map(Object.entries(out.targets)), TOP),
94
+ readBeforeEditRate: edits ? out.readFirst / edits : null,
95
+ searchDiscipline: searches ? out.searchViaTool / searches : null,
96
+ failureRate: out.calls ? out.failures / out.calls : 0,
97
+ };
98
+ }
99
+
100
+ function bump(map, key) {
101
+ map.set(key, (map.get(key) ?? 0) + 1);
102
+ }
103
+
104
+ function top(map, limit) {
105
+ return Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit));
106
+ }
107
+
108
+ const BUCKETS = [100, 500, 1000, 5000, 15000, 60000];
109
+
110
+ function bucketize(values) {
111
+ const out = {};
112
+ for (const value of values) {
113
+ const edge = BUCKETS.find((limit) => value < limit) ?? 'slow';
114
+ const key = edge === 'slow' ? '60000+' : `<${edge}`;
115
+ out[key] = (out[key] ?? 0) + 1;
116
+ }
117
+ return out;
118
+ }
@@ -0,0 +1,98 @@
1
+ /** Sessions, prompts, tool calls, files, commits — the counting floor everything else divides by. */
2
+
3
+ import { KIND } from '../../ledger/schema.mjs';
4
+
5
+ export const name = 'volume';
6
+
7
+ export function create() {
8
+ return {
9
+ sessions: new Set(), projects: new Set(), files: new Set(),
10
+ prompts: 0, toolCalls: 0, writes: 0, commits: 0,
11
+ insertions: 0, deletions: 0, filesChanged: 0,
12
+ // Which agent each record came from. This is what makes cross-agent
13
+ // comparison possible at all — everything else divides by these.
14
+ bySource: new Map(),
15
+ };
16
+ }
17
+
18
+ export function add(state, record) {
19
+ if (record.session) state.sessions.add(record.session);
20
+ if (record.project) state.projects.add(record.project);
21
+
22
+ const source = record.source ?? 'claude-code';
23
+ if (!state.bySource.has(source)) state.bySource.set(source, { records: 0, prompts: 0, toolCalls: 0, sessions: new Set() });
24
+ const bucket = state.bySource.get(source);
25
+ bucket.records += 1;
26
+ if (record.session) bucket.sessions.add(record.session);
27
+ if (record.kind === KIND.PROMPT) bucket.prompts += 1;
28
+ if (record.kind === KIND.TOOL_POST) bucket.toolCalls += 1;
29
+
30
+ switch (record.kind) {
31
+ case KIND.PROMPT: state.prompts += 1; break;
32
+ case KIND.TOOL_POST: state.toolCalls += 1; break;
33
+ case KIND.FILE_TOUCH:
34
+ state.writes += 1;
35
+ if (record.data.path) state.files.add(record.data.path);
36
+ break;
37
+ case KIND.GIT_COMMIT:
38
+ state.commits += 1;
39
+ state.insertions += record.data.insertions ?? 0;
40
+ state.deletions += record.data.deletions ?? 0;
41
+ state.filesChanged += record.data.files ?? 0;
42
+ break;
43
+ default: break;
44
+ }
45
+ }
46
+
47
+ export function done(state) {
48
+ return {
49
+ sessions: [...state.sessions],
50
+ projects: [...state.projects],
51
+ files: [...state.files].slice(0, 500),
52
+ prompts: state.prompts,
53
+ toolCalls: state.toolCalls,
54
+ writes: state.writes,
55
+ commits: state.commits,
56
+ insertions: state.insertions,
57
+ deletions: state.deletions,
58
+ filesChanged: state.filesChanged,
59
+ bySource: Object.fromEntries([...state.bySource].map(([id, b]) => [id, {
60
+ records: b.records, prompts: b.prompts, toolCalls: b.toolCalls, sessions: [...b.sessions],
61
+ }])),
62
+ };
63
+ }
64
+
65
+ export function merge(parts) {
66
+ const sessions = new Set();
67
+ const projects = new Set();
68
+ const files = new Set();
69
+ const totals = { prompts: 0, toolCalls: 0, writes: 0, commits: 0, insertions: 0, deletions: 0, filesChanged: 0 };
70
+
71
+ const bySource = {};
72
+ for (const part of parts) {
73
+ for (const id of part.sessions ?? []) sessions.add(id);
74
+ for (const id of part.projects ?? []) projects.add(id);
75
+ for (const file of part.files ?? []) files.add(file);
76
+ for (const key of Object.keys(totals)) totals[key] += part[key] ?? 0;
77
+
78
+ for (const [id, bucket] of Object.entries(part.bySource ?? {})) {
79
+ bySource[id] ??= { records: 0, prompts: 0, toolCalls: 0, sessions: new Set() };
80
+ bySource[id].records += bucket.records ?? 0;
81
+ bySource[id].prompts += bucket.prompts ?? 0;
82
+ bySource[id].toolCalls += bucket.toolCalls ?? 0;
83
+ for (const s of bucket.sessions ?? []) bySource[id].sessions.add(s);
84
+ }
85
+ }
86
+
87
+ return {
88
+ ...totals,
89
+ sessionCount: sessions.size,
90
+ projectCount: projects.size,
91
+ fileCount: files.size,
92
+ sessions: [...sessions],
93
+ projects: [...projects],
94
+ bySource: Object.fromEntries(Object.entries(bySource).map(([id, b]) => [id, {
95
+ records: b.records, prompts: b.prompts, toolCalls: b.toolCalls, sessions: b.sessions.size,
96
+ }])),
97
+ };
98
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Time bucketing, in the user's LOCAL timezone.
3
+ *
4
+ * Segments are stored in UTC (ledger/segments.mjs) precisely so this module can
5
+ * be the only place that knows about local time. "Today", "this week" and the
6
+ * hour-of-day heatmap all mean local time to a human and would be quietly wrong
7
+ * in UTC — quietly is the problem, since nobody double-checks a heatmap.
8
+ *
9
+ * DST is handled by letting Date do the arithmetic on calendar fields rather
10
+ * than by adding 86,400,000 milliseconds, which is wrong twice a year.
11
+ */
12
+
13
+ export const DAY_MS = 86_400_000;
14
+
15
+ export function localDay(ts) {
16
+ const date = new Date(ts);
17
+ const month = String(date.getMonth() + 1).padStart(2, '0');
18
+ const day = String(date.getDate()).padStart(2, '0');
19
+ return `${date.getFullYear()}-${month}-${day}`;
20
+ }
21
+
22
+ /** Half-open [start, end). Calendar arithmetic, so a 23- or 25-hour day works. */
23
+ export function dayBounds(day) {
24
+ const [year, month, date] = day.split('-').map(Number);
25
+ return {
26
+ start: new Date(year, month - 1, date, 0, 0, 0, 0).getTime(),
27
+ end: new Date(year, month - 1, date + 1, 0, 0, 0, 0).getTime(),
28
+ };
29
+ }
30
+
31
+ export function shiftDay(day, delta) {
32
+ const [year, month, date] = day.split('-').map(Number);
33
+ return localDay(new Date(year, month - 1, date + delta, 12).getTime());
34
+ }
35
+
36
+ export function daysBetween(from, to) {
37
+ const out = [];
38
+ let day = from;
39
+ while (day <= to) {
40
+ out.push(day);
41
+ day = shiftDay(day, 1);
42
+ if (out.length > 4000) break; // a decade of days is a bug, not a query
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * @param {string} spec today | yesterday | 7d | 30d | 90d | week | month | all
49
+ * | a literal YYYY-MM-DD | 'YYYY-MM-DD..YYYY-MM-DD'
50
+ * @returns {{from: number, to: number, days: string[], label: string, spec: string}}
51
+ */
52
+ export function rangeFor(spec = '7d', now = Date.now()) {
53
+ const today = localDay(now);
54
+
55
+ if (spec === 'all') {
56
+ return build(shiftDay(today, -3650), today, 'all time', spec);
57
+ }
58
+ if (spec === 'today') return build(today, today, 'today', spec);
59
+ if (spec === 'yesterday') {
60
+ const yesterday = shiftDay(today, -1);
61
+ return build(yesterday, yesterday, 'yesterday', spec);
62
+ }
63
+ if (/^\d{4}-\d{2}-\d{2}$/.test(spec)) return build(spec, spec, spec, spec);
64
+
65
+ const explicit = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(spec);
66
+ if (explicit) return build(explicit[1], explicit[2], `${explicit[1]} to ${explicit[2]}`, spec);
67
+
68
+ const relative = /^(\d+)d$/.exec(spec);
69
+ const count = relative ? Number(relative[1]) : { week: 7, month: 30, quarter: 90 }[spec] ?? 7;
70
+ return build(shiftDay(today, -(count - 1)), today, `last ${count} days`, spec);
71
+ }
72
+
73
+ /** The equally sized period immediately before a range, for deltas. */
74
+ export function previousRange(range) {
75
+ const length = range.days.length;
76
+ const to = shiftDay(range.days[0], -1);
77
+ const from = shiftDay(to, -(length - 1));
78
+ return build(from, to, `previous ${length} days`, `${from}..${to}`);
79
+ }
80
+
81
+ function build(from, to, label, spec) {
82
+ const days = daysBetween(from, to);
83
+ return {
84
+ from: dayBounds(from).start,
85
+ to: dayBounds(to).end,
86
+ days,
87
+ label,
88
+ spec,
89
+ };
90
+ }
91
+
92
+ export function formatDuration(ms) {
93
+ if (!ms || ms < 0) return '0m';
94
+ const minutes = Math.round(ms / 60_000);
95
+ if (minutes < 60) return `${minutes}m`;
96
+ const hours = Math.floor(minutes / 60);
97
+ return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
98
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Incremental daily rollups in rollup/YYYY-MM-DD.json.
3
+ *
4
+ * The dashboard must open instantly on a year of data. Re-reading the chain per
5
+ * request would not, so each finished local day is folded once and cached; only
6
+ * today (and yesterday, until it settles) is recomputed.
7
+ *
8
+ * A rollup is a cache and is always safe to delete — `syndes rebuild`
9
+ * regenerates every one of them from the chain, which is the only source of truth.
10
+ */
11
+
12
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
13
+ import { rollupDir, rollupFile } from '../runtime/paths.mjs';
14
+ import { loadConfig } from '../runtime/config.mjs';
15
+ import { readRange } from '../ledger/read.mjs';
16
+ import { listDays } from '../ledger/segments.mjs';
17
+ import { localDay, dayBounds, daysBetween, shiftDay } from './ranges.mjs';
18
+ import { debug } from '../runtime/log.mjs';
19
+
20
+ import * as volume from './metrics/volume.mjs';
21
+ import * as time from './metrics/time.mjs';
22
+ import * as tokens from './metrics/tokens.mjs';
23
+ import * as tools from './metrics/tools.mjs';
24
+ import * as context from './metrics/context.mjs';
25
+ import * as friction from './metrics/friction.mjs';
26
+ import * as rework from './metrics/rework.mjs';
27
+ import * as prompts from './metrics/prompts.mjs';
28
+
29
+ export const METRICS = [volume, time, tokens, tools, context, friction, rework, prompts];
30
+
31
+ /** Bumped whenever a fold changes shape, so stale caches rebuild themselves. */
32
+ export const ROLLUP_VERSION = 1;
33
+
34
+ export async function buildDay(day) {
35
+ const config = loadConfig();
36
+ const { start, end } = dayBounds(day);
37
+ const ctx = { day, start, end, idleGapMs: config.idleGapMinutes * 60_000 };
38
+
39
+ const states = METRICS.map((metric) => metric.create());
40
+ let records = 0;
41
+ let lastSeq = -1;
42
+
43
+ for await (const record of readRange(start, end)) {
44
+ records += 1;
45
+ lastSeq = record.seq;
46
+ for (let index = 0; index < METRICS.length; index += 1) {
47
+ METRICS[index].add(states[index], record, ctx);
48
+ }
49
+ }
50
+
51
+ const metrics = {};
52
+ for (let index = 0; index < METRICS.length; index += 1) {
53
+ metrics[METRICS[index].name] = METRICS[index].done(states[index]);
54
+ }
55
+
56
+ return {
57
+ day,
58
+ version: ROLLUP_VERSION,
59
+ records,
60
+ lastSeq,
61
+ builtAt: Date.now(),
62
+ // A day is final once it is over. Final rollups are never rebuilt.
63
+ final: Date.now() >= end,
64
+ metrics,
65
+ };
66
+ }
67
+
68
+ export function loadRollup(day) {
69
+ const file = rollupFile(day);
70
+ if (!existsSync(file)) return null;
71
+ try {
72
+ const rollup = JSON.parse(readFileSync(file, 'utf8'));
73
+ return rollup.version === ROLLUP_VERSION ? rollup : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function writeRollup(rollup) {
80
+ try {
81
+ mkdirSync(rollupDir, { recursive: true });
82
+ writeFileSync(rollupFile(rollup.day), `${JSON.stringify(rollup)}\n`);
83
+ } catch (error) {
84
+ debug('rollup write failed', rollup.day, error.message);
85
+ }
86
+ }
87
+
88
+ /** Every local day the ledger might have records for. */
89
+ export function coveredDays() {
90
+ const utcDays = listDays();
91
+ if (!utcDays.length) return [];
92
+ // A UTC day spills into the local day on either side of it, depending which
93
+ // way the offset runs. Widening by one on each end is cheaper than being wrong.
94
+ return daysBetween(shiftDay(utcDays[0], -1), shiftDay(utcDays[utcDays.length - 1], 1))
95
+ .filter((day) => day <= localDay(Date.now()));
96
+ }
97
+
98
+ /**
99
+ * Bring the cache up to date. Called by the worker after every append.
100
+ *
101
+ * Rebuilds any day with no valid rollup, plus every non-final one. `all` forces
102
+ * a full rebuild, which is what `syndes rebuild` runs.
103
+ */
104
+ export async function updateRollups({ all = false } = {}) {
105
+ const days = coveredDays();
106
+ const built = [];
107
+
108
+ for (const day of days) {
109
+ const cached = all ? null : loadRollup(day);
110
+ if (cached?.final) continue;
111
+
112
+ const rollup = await buildDay(day);
113
+ writeRollup(rollup);
114
+ built.push(day);
115
+ }
116
+ return { built, days: days.length };
117
+ }
118
+
119
+ /** Rollups for a range, building anything missing on demand. */
120
+ export async function rollupsFor(range) {
121
+ const out = [];
122
+ for (const day of range.days) {
123
+ let rollup = loadRollup(day);
124
+ if (!rollup || !rollup.final) {
125
+ rollup = await buildDay(day);
126
+ if (rollup.records || rollup.final) writeRollup(rollup);
127
+ }
128
+ out.push(rollup);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Combine day rollups into one summary per metric. */
134
+ export function mergeRollups(rollups) {
135
+ const merged = {};
136
+ for (const metric of METRICS) {
137
+ merged[metric.name] = metric.merge(rollups.map((rollup) => rollup.metrics?.[metric.name] ?? {}));
138
+ }
139
+ merged.records = rollups.reduce((sum, rollup) => sum + (rollup.records ?? 0), 0);
140
+ merged.days = rollups.length;
141
+ merged.activeDays = rollups.filter((rollup) => (rollup.records ?? 0) > 0).length;
142
+ return merged;
143
+ }
144
+
145
+ export function clearRollups() {
146
+ try {
147
+ for (const name of readdirSync(rollupDir)) {
148
+ if (name.endsWith('.json')) unlinkSync(rollupFile(name.replace('.json', '')));
149
+ }
150
+ } catch { /* nothing cached */ }
151
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The Efficiency Score: 0–100, five weighted pillars.
3
+ *
4
+ * Two rules this file exists to enforce:
5
+ *
6
+ * 1. A pillar with no data scores `null`, never zero. Punishing someone for a
7
+ * metric we could not measure is the fastest way to make a score ignorable.
8
+ * The total re-normalises over the pillars that actually have evidence.
9
+ * 2. Every part carries its evidence — the number, and the thing that produced
10
+ * it — so the UI can open any figure and land on the records behind it.
11
+ * A score with no drill-down is a horoscope.
12
+ */
13
+
14
+ export const PILLARS = [
15
+ { key: 'context', label: 'Context hygiene', weight: 25 },
16
+ { key: 'tools', label: 'Tool discipline', weight: 20 },
17
+ { key: 'feedback', label: 'Feedback loop', weight: 20 },
18
+ { key: 'prompts', label: 'Prompt quality', weight: 20 },
19
+ { key: 'flow', label: 'Flow & friction', weight: 15 },
20
+ ];
21
+
22
+ const clamp = (value) => Math.max(0, Math.min(100, Math.round(value)));
23
+
24
+ /**
25
+ * A part with no computable score is absent, not zero.
26
+ *
27
+ * The non-finite guard is load-bearing: a metric that is present but empty
28
+ * yields NaN through the arithmetic, and NaN propagating into the total would
29
+ * present "we could not measure this" as "you scored nothing".
30
+ */
31
+ function part(label, weight, score, value, evidence) {
32
+ if (score === null || score === undefined || !Number.isFinite(score)) return null;
33
+ return { label, weight, score: clamp(score), value, evidence };
34
+ }
35
+
36
+ function combine(parts) {
37
+ const present = parts.filter(Boolean);
38
+ if (!present.length) return { score: null, parts: [] };
39
+ const weight = present.reduce((sum, item) => sum + item.weight, 0);
40
+ const score = present.reduce((sum, item) => sum + item.score * item.weight, 0) / weight;
41
+ return { score: clamp(score), parts: present };
42
+ }
43
+
44
+ /**
45
+ * @param {object} merged from rollup.mergeRollups()
46
+ * @returns {{total, confidence, pillars, missing}}
47
+ */
48
+ export function score(merged) {
49
+ const { context, tools, rework, prompts, friction, time, volume, tokens } = merged;
50
+
51
+ const sessions = volume?.sessionCount ?? 0;
52
+ const activeHours = (time?.activeMs ?? 0) / 3_600_000;
53
+
54
+ const pillars = {
55
+ context: combine([
56
+ sessions
57
+ ? part('Ran out of context', 60,
58
+ 100 - (context.autoCompacts / sessions) * 55,
59
+ context.autoCompacts / sessions,
60
+ `${context.autoCompacts} automatic compaction${context.autoCompacts === 1 ? '' : 's'} across ${sessions} session${sessions === 1 ? '' : 's'}`)
61
+ : null,
62
+ context?.starts
63
+ ? part('Continued vs restarted', 20,
64
+ 40 + context.resumeRate * 60,
65
+ context.resumeRate,
66
+ `${context.resumed} of ${context.starts} sessions resumed rather than started cold`)
67
+ : null,
68
+ context?.avgTokensBeforeCompact
69
+ ? part('Window used before compacting', 20,
70
+ Math.min(100, (context.avgTokensBeforeCompact / 150_000) * 100),
71
+ context.avgTokensBeforeCompact,
72
+ `${Math.round(context.avgTokensBeforeCompact / 1000)}k tokens in context at the average compact`)
73
+ : null,
74
+ ]),
75
+
76
+ tools: combine([
77
+ tools?.readBeforeEditRate === null || tools?.readBeforeEditRate === undefined
78
+ ? null
79
+ : part('Read before editing', 40,
80
+ tools.readBeforeEditRate * 100,
81
+ tools.readBeforeEditRate,
82
+ `${tools.readFirst} of ${tools.readFirst + tools.blindEdit} edits touched a file the session had read`),
83
+ tokens?.turnsWithTools
84
+ ? part('Calls batched per turn', 30,
85
+ // One call per turn is a full round-trip each; three is efficient.
86
+ ((tokens.callsPerTurn - 1) / 2) * 100,
87
+ tokens.callsPerTurn,
88
+ `${tokens.callsPerTurn.toFixed(2)} tool calls per turn that used tools`)
89
+ : null,
90
+ tools?.searchDiscipline === null || tools?.searchDiscipline === undefined
91
+ ? null
92
+ : part('Search tools vs bash grep', 30,
93
+ tools.searchDiscipline * 100,
94
+ tools.searchDiscipline,
95
+ `${tools.searchViaBash} of ${tools.searchViaBash + tools.searchViaTool} searches went through Bash`),
96
+ ]),
97
+
98
+ feedback: combine([
99
+ rework?.verifyRate === null || rework?.verifyRate === undefined
100
+ ? null
101
+ : part('Verified after changing', 45,
102
+ rework.verifyRate * 100,
103
+ rework.verifyRate,
104
+ `${rework.verifiedRuns} of ${rework.verifiedRuns + rework.unverifiedRuns} edit runs ended in a test or build`),
105
+ rework?.calls
106
+ ? part('Tool failure rate', 35,
107
+ 100 - rework.errorRate * 400,
108
+ rework.errorRate,
109
+ `${rework.errors} failures in ${rework.calls} tool calls`)
110
+ : null,
111
+ rework?.calls
112
+ ? part('Retry storms', 20,
113
+ 100 - rework.stormCount * 25,
114
+ rework.stormCount,
115
+ rework.stormCount
116
+ ? `${rework.stormCount} command${rework.stormCount === 1 ? '' : 's'} retried 3+ times without a change`
117
+ : 'no command retried 3+ times unchanged')
118
+ : null,
119
+ ]),
120
+
121
+ prompts: combine([
122
+ prompts?.count
123
+ ? part('Corrections', 40,
124
+ 100 - prompts.correctionRate * 350,
125
+ prompts.correctionRate,
126
+ `${prompts.corrections} of ${prompts.count} prompts read as a correction`)
127
+ : null,
128
+ prompts?.reprompRate === null || prompts?.reprompRate === undefined
129
+ ? null
130
+ : part('Re-prompts without progress', 35,
131
+ 100 - prompts.reprompRate * 200,
132
+ prompts.reprompRate,
133
+ `${prompts.followUp} prompts followed one that produced no tool call`),
134
+ prompts?.count
135
+ ? part('Prompt detail', 25,
136
+ // Too short under-specifies; past a point more words stop helping.
137
+ Math.min(100, (prompts.medianWords ?? 0) / 35 * 100),
138
+ prompts.medianWords,
139
+ `${prompts.medianWords} words in the median prompt`)
140
+ : null,
141
+ ]),
142
+
143
+ flow: combine([
144
+ activeHours >= 0.1
145
+ ? part('Permission interruptions', 45,
146
+ 100 - (friction.blocks / activeHours) * 12,
147
+ friction.blocks / activeHours,
148
+ `${(friction.blocks / activeHours).toFixed(1)} permission stops per active hour`)
149
+ : null,
150
+ friction?.blocks
151
+ ? part('Time spent blocked', 25,
152
+ 100 - (friction.blockedMs / 1000 / 60) * 2,
153
+ friction.blockedMs,
154
+ `${Math.round(friction.blockedMs / 60_000)} minutes waiting on permission prompts`)
155
+ : null,
156
+ time?.wallMs
157
+ ? part('Focus', 30,
158
+ time.activeShare * 130,
159
+ time.activeShare,
160
+ `${Math.round(time.activeShare * 100)}% of session wall-clock was active work`)
161
+ : null,
162
+ ]),
163
+ };
164
+
165
+ let total = 0;
166
+ let weight = 0;
167
+ const missing = [];
168
+ for (const pillar of PILLARS) {
169
+ const result = pillars[pillar.key];
170
+ if (result.score === null) { missing.push(pillar.key); continue; }
171
+ total += result.score * pillar.weight;
172
+ weight += pillar.weight;
173
+ }
174
+
175
+ return {
176
+ total: weight ? clamp(total / weight) : null,
177
+ weightCovered: weight,
178
+ confidence: confidenceOf(merged),
179
+ pillars: PILLARS.map((pillar) => ({ ...pillar, ...pillars[pillar.key] })),
180
+ missing,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * A score computed from three tool calls is noise. Saying so is more useful than
186
+ * a confident number, so the UI can show the caveat instead of the illusion.
187
+ */
188
+ function confidenceOf(merged) {
189
+ const calls = merged.tools?.calls ?? 0;
190
+ const sessions = merged.volume?.sessionCount ?? 0;
191
+ if (calls >= 300 && sessions >= 5) return 'high';
192
+ if (calls >= 60 && sessions >= 2) return 'medium';
193
+ return 'low';
194
+ }