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,97 @@
1
+ /**
2
+ * The noise governor, and the most important file in this directory.
3
+ *
4
+ * A coaching tool that cannot be shut up gets uninstalled, and an uninstalled
5
+ * ledger records nothing. The budget is not a nicety — it is what keeps the
6
+ * ledger alive.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ import { coachFile } from '../runtime/paths.mjs';
12
+ import { localDay } from '../analytics/ranges.mjs';
13
+ import { debug } from '../runtime/log.mjs';
14
+
15
+ /** Three dismissals and the rule stops asking. It does not get a fourth chance. */
16
+ const DISMISSALS_TO_MUTE = 3;
17
+
18
+ export function load() {
19
+ try {
20
+ return JSON.parse(readFileSync(coachFile, 'utf8'));
21
+ } catch {
22
+ return { lastByRule: {}, sent: {}, dismissed: {}, muted: [], snoozedUntil: 0 };
23
+ }
24
+ }
25
+
26
+ export function save(state) {
27
+ try {
28
+ mkdirSync(dirname(coachFile), { recursive: true });
29
+ const staging = `${coachFile}.tmp`;
30
+ writeFileSync(staging, `${JSON.stringify(state, null, 2)}\n`);
31
+ renameSync(staging, coachFile);
32
+ } catch (error) {
33
+ debug('coach state save failed', error.message);
34
+ }
35
+ }
36
+
37
+ /** @returns {{allowed: boolean, reason: string|null}} */
38
+ export function allows(state, ruleId, config, now = Date.now()) {
39
+ if (!config.coach.enabled) return { allowed: false, reason: 'coaching is off' };
40
+ if (now < (state.snoozedUntil ?? 0)) return { allowed: false, reason: 'snoozed' };
41
+ if (state.muted?.includes(ruleId) || config.coach.muted?.includes(ruleId)) {
42
+ return { allowed: false, reason: 'muted' };
43
+ }
44
+
45
+ const today = localDay(now);
46
+ const sentToday = state.sent?.[today] ?? 0;
47
+ if (sentToday >= config.coach.maxPerDay) return { allowed: false, reason: 'daily budget spent' };
48
+
49
+ const last = state.lastByRule?.[ruleId] ?? 0;
50
+ const cooldown = config.coach.cooldownHours * 3_600_000;
51
+ if (now - last < cooldown) return { allowed: false, reason: 'cooling down' };
52
+
53
+ return { allowed: true, reason: null };
54
+ }
55
+
56
+ export function record(state, ruleId, now = Date.now()) {
57
+ const today = localDay(now);
58
+ state.lastByRule ??= {};
59
+ state.sent ??= {};
60
+ state.lastByRule[ruleId] = now;
61
+ state.sent[today] = (state.sent[today] ?? 0) + 1;
62
+
63
+ // Keep the counter file from growing forever; two weeks is plenty of history.
64
+ for (const day of Object.keys(state.sent)) {
65
+ if (Object.keys(state.sent).length > 14 && day < today) delete state.sent[day];
66
+ }
67
+ return state;
68
+ }
69
+
70
+ export function dismiss(state, ruleId) {
71
+ state.dismissed ??= {};
72
+ state.dismissed[ruleId] = (state.dismissed[ruleId] ?? 0) + 1;
73
+ if (state.dismissed[ruleId] >= DISMISSALS_TO_MUTE) {
74
+ state.muted ??= [];
75
+ if (!state.muted.includes(ruleId)) state.muted.push(ruleId);
76
+ }
77
+ return state;
78
+ }
79
+
80
+ export function mute(state, ruleId) {
81
+ state.muted ??= [];
82
+ if (!state.muted.includes(ruleId)) state.muted.push(ruleId);
83
+ return state;
84
+ }
85
+
86
+ export function unmute(state, ruleId) {
87
+ state.muted = (state.muted ?? []).filter((id) => id !== ruleId);
88
+ state.dismissed && delete state.dismissed[ruleId];
89
+ return state;
90
+ }
91
+
92
+ export function snooze(state, ms, now = Date.now()) {
93
+ state.snoozedUntil = now + ms;
94
+ return state;
95
+ }
96
+
97
+ export { DISMISSALS_TO_MUTE };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The human-readable half of every rule: what it is called, the one line the
3
+ * user sees, why it matters, and what to actually do.
4
+ *
5
+ * Separated from detection so wording can be edited and tuned without touching
6
+ * logic that has tests pinned to it.
7
+ */
8
+
9
+ export const CATALOG = {
10
+ 'read-before-edit': {
11
+ title: 'Editing files blind',
12
+ why: 'An edit to a file the session has not read is the strongest single predictor of an edit that has to be redone. The model is writing against a guess at the current contents.',
13
+ fix: 'Ask for the file to be read first, or point at the exact function rather than the file.',
14
+ },
15
+ 'batch-tool-calls': {
16
+ title: 'One tool call per turn',
17
+ why: 'Independent calls issued in one turn cost one round-trip. The same calls spread across separate turns cost one round-trip each, plus the input tokens of the whole conversation again every time.',
18
+ fix: 'Ask for the reads or searches you need together — "read these four files" rather than four requests.',
19
+ },
20
+ 'verify-after-change': {
21
+ title: 'Changes are not being verified',
22
+ why: 'A run of edits with no test, build or typecheck between them and the end of the session is the loop that feels productive and lands broken.',
23
+ fix: 'End a change with the project\'s check command, and say which one it is up front.',
24
+ },
25
+ 'context-hygiene': {
26
+ title: 'Sessions keep running out of context',
27
+ why: 'An automatic compaction means the window filled up and had to be summarised. Everything after it is working from a lossy summary, and you paid full price for the tokens that got discarded.',
28
+ fix: 'Start a fresh session per task, and put the standing context in CLAUDE.md so it does not have to be re-explained.',
29
+ },
30
+ 'prompt-specificity': {
31
+ title: 'A lot of prompts are corrections',
32
+ why: 'A correction means the previous turn missed. The cost is not the correction — it is the whole turn that was thrown away, at full token price.',
33
+ fix: 'Name the file, the function and the expected outcome in the first prompt.',
34
+ },
35
+ 'delegate-wide-search': {
36
+ title: 'Wide searches are running in the main thread',
37
+ why: 'Every grep result lands in your context and stays there for the rest of the session. A subagent reads the same files and returns only the conclusion.',
38
+ fix: 'For "where is X handled" questions, ask for a subagent to search and report back.',
39
+ },
40
+ 'permission-friction': {
41
+ title: 'The same permission prompt keeps stopping you',
42
+ why: 'Each stop is a context switch, and you are answering the same question repeatedly. This one is fixable in about ten seconds.',
43
+ fix: 'Add the tool to the allowlist in settings.json, or run /permissions.',
44
+ },
45
+ 'retry-storm': {
46
+ title: 'A command is being retried unchanged',
47
+ why: 'The same failing command run again with nothing changed in between cannot succeed, and each attempt spends tokens on the same error output.',
48
+ fix: 'Stop the loop and read the error yourself, or say explicitly what changed.',
49
+ },
50
+ 'session-sprawl': {
51
+ title: 'Sessions are staying open far longer than they are used',
52
+ why: 'A long-idle session is not free: resuming it carries the whole accumulated context, and it makes every per-hour figure in your own metrics meaningless.',
53
+ fix: 'Close a session when the task is done. Start the next one fresh.',
54
+ },
55
+ 'project-memory': {
56
+ title: 'A project you work in often has no CLAUDE.md',
57
+ why: 'Without it, the same background — how to run tests, where things live, house style — is re-established from scratch every session, at full token price each time.',
58
+ fix: 'Run /init in that project, or write a short CLAUDE.md by hand.',
59
+ },
60
+ };
61
+
62
+ export function describe(id) {
63
+ return CATALOG[id] ?? { title: id, why: '', fix: '' };
64
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Nudge → the right channel for the moment.
3
+ *
4
+ * Every nudge carries its evidence — "you edited src/app.ts 6 times in 12
5
+ * minutes without running tests", not "consider running tests". A nudge without
6
+ * evidence is a fortune cookie, and people stop reading fortune cookies.
7
+ */
8
+
9
+ import { writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { stateDir } from '../runtime/paths.mjs';
12
+ import { notify } from '../notify/index.mjs';
13
+ import { debug } from '../runtime/log.mjs';
14
+
15
+ const CARD_FILE = join(stateDir, 'card.txt');
16
+
17
+ export async function deliver(finding, config) {
18
+ const channels = [];
19
+
20
+ if (config.coach.channels.notification) {
21
+ const sent = await notify({
22
+ title: 'SynDes',
23
+ subtitle: finding.title,
24
+ message: finding.evidence,
25
+ });
26
+ if (sent) channels.push('notification');
27
+ }
28
+
29
+ if (config.coach.channels.terminal) {
30
+ if (writeCard(finding)) channels.push('terminal');
31
+ }
32
+
33
+ return { channels };
34
+ }
35
+
36
+ /**
37
+ * The terminal channel: a card the next SessionStart hook prints.
38
+ *
39
+ * The worker cannot write into a running session, so it leaves the card here.
40
+ * Plain text, because this is printed by a hook whose stdout Claude Code reads —
41
+ * anything cleverer risks becoming model context instead of a message.
42
+ */
43
+ function writeCard(finding) {
44
+ const width = 72;
45
+ const line = '─'.repeat(width - 2);
46
+
47
+ // Every wrap width is the row's usable space minus its own indent. Getting
48
+ // this wrong makes a line both wrap AND truncate, which is how you end up
49
+ // with an ellipsis mid-sentence followed by the rest of the sentence.
50
+ const body = (text) => wrap(text, width - 4).map((part) => row(` ${part}`, width));
51
+ // The arrow marks the start of an instruction, so continuation lines are
52
+ // indented under it rather than repeating it.
53
+ const bullet = (text) => wrap(text, width - 6)
54
+ .map((part, index) => row(index === 0 ? ` → ${part}` : ` ${part}`, width));
55
+
56
+ const rows = [
57
+ `\n┌${line}┐`,
58
+ row(`SynDes · ${finding.title}`, width),
59
+ `├${line}┤`,
60
+ ...body(finding.evidence),
61
+ row('', width),
62
+ ...bullet(finding.fix),
63
+ ...(finding.action ? bullet(finding.action) : []),
64
+ `└${line}┘`,
65
+ ` syndes dashboard for the detail · syndes mute ${finding.id} to stop this\n`,
66
+ ];
67
+
68
+ try {
69
+ mkdirSync(dirname(CARD_FILE), { recursive: true });
70
+ writeFileSync(CARD_FILE, rows.join('\n'));
71
+ return true;
72
+ } catch (error) {
73
+ debug('card write failed', error.message);
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function row(text, width) {
79
+ const inner = width - 2;
80
+ const clipped = text.length > inner ? `${text.slice(0, inner - 1)}…` : text;
81
+ return `│${clipped.padEnd(inner)}│`;
82
+ }
83
+
84
+ function wrap(text, width) {
85
+ if (!text) return [];
86
+ const words = String(text).split(/\s+/);
87
+ const lines = [];
88
+ let current = '';
89
+ for (const word of words) {
90
+ if ((`${current} ${word}`).trim().length > width) {
91
+ if (current) lines.push(current.trim());
92
+ current = word;
93
+ } else {
94
+ current = `${current} ${word}`;
95
+ }
96
+ }
97
+ if (current.trim()) lines.push(current.trim());
98
+ return lines;
99
+ }
100
+
101
+ export { CARD_FILE, wrap };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Evaluate practice rules over a window and pick at most one nudge.
3
+ *
4
+ * Runs ONLY at breakpoints — Stop, SessionEnd, SubagentStop. Never mid-turn.
5
+ * Interrupting someone to tell them to work better is self-defeating design.
6
+ *
7
+ * A rule is { id, pillar, severity, window, detect(merged) → evidence | null }.
8
+ * detect() is pure over pre-computed metrics, so rules are unit-testable against
9
+ * a fixture ledger and adding one costs nothing at runtime.
10
+ */
11
+
12
+ import { RULES, ruleById } from './rules/index.mjs';
13
+ import { describe } from './catalog.mjs';
14
+ import * as budget from './budget.mjs';
15
+ import { deliver } from './deliver.mjs';
16
+ import { loadConfig } from '../runtime/config.mjs';
17
+ import { rangeFor } from '../analytics/ranges.mjs';
18
+ import { rollupsFor, mergeRollups } from '../analytics/rollup.mjs';
19
+ import { append } from '../ledger/append.mjs';
20
+ import { draft, KIND } from '../ledger/schema.mjs';
21
+ import { debug } from '../runtime/log.mjs';
22
+
23
+ /**
24
+ * Evaluate every rule and return what fired, regardless of budget.
25
+ *
26
+ * This is what the dashboard shows: the full picture, including rules that are
27
+ * firing but muted. Hiding a muted finding would make the mute look like a fix.
28
+ */
29
+ export async function evaluate({ now = Date.now() } = {}) {
30
+ const config = loadConfig();
31
+ const state = budget.load();
32
+ const cache = new Map();
33
+ const findings = [];
34
+
35
+ for (const rule of RULES) {
36
+ let merged = cache.get(rule.window);
37
+ if (!merged) {
38
+ merged = mergeRollups(await rollupsFor(rangeFor(rule.window, now)));
39
+ cache.set(rule.window, merged);
40
+ }
41
+
42
+ let hit = null;
43
+ try {
44
+ hit = rule.detect(merged);
45
+ } catch (error) {
46
+ // A broken rule must not take the coach down with it.
47
+ debug('rule failed', rule.id, error.message);
48
+ continue;
49
+ }
50
+ if (!hit) continue;
51
+
52
+ const gate = budget.allows(state, rule.id, config, now);
53
+ findings.push({
54
+ id: rule.id,
55
+ pillar: rule.pillar,
56
+ severity: rule.severity,
57
+ window: rule.window,
58
+ ...describe(rule.id),
59
+ ...hit,
60
+ deliverable: gate.allowed,
61
+ blockedBy: gate.reason,
62
+ lastSent: state.lastByRule?.[rule.id] ?? null,
63
+ muted: (state.muted ?? []).includes(rule.id),
64
+ });
65
+ }
66
+
67
+ // Most severe first, then whichever has gone longest without being said.
68
+ findings.sort((a, b) => b.severity - a.severity || (a.lastSent ?? 0) - (b.lastSent ?? 0));
69
+ return findings;
70
+ }
71
+
72
+ /**
73
+ * Evaluate, pick one, deliver it, and write it into the ledger.
74
+ *
75
+ * Exactly one per run. The alternative is a wall of advice at every Stop, which
76
+ * is how a coach becomes something you learn to scroll past.
77
+ */
78
+ export async function runCoach({ now = Date.now(), force = false } = {}) {
79
+ const config = loadConfig();
80
+ if (!config.coach.enabled && !force) return { sent: null, reason: 'coaching is off' };
81
+
82
+ const findings = await evaluate({ now });
83
+ const chosen = findings.find((finding) => finding.deliverable || force);
84
+ if (!chosen) {
85
+ return { sent: null, reason: findings.length ? findings[0].blockedBy : 'nothing to say', findings };
86
+ }
87
+
88
+ const delivered = await deliver(chosen, config);
89
+
90
+ const state = budget.record(budget.load(), chosen.id, now);
91
+ budget.save(state);
92
+
93
+ // The nudge goes into the ledger too. What the coach told you, and when, is
94
+ // part of the record — otherwise the advice is unauditable.
95
+ await append([draft(KIND.COACH, {
96
+ ts: now,
97
+ data: {
98
+ rule: chosen.id, pillar: chosen.pillar, severity: chosen.severity,
99
+ value: chosen.value ?? null, evidence: chosen.evidence,
100
+ channels: delivered.channels,
101
+ },
102
+ })]);
103
+
104
+ return { sent: chosen, channels: delivered.channels, findings };
105
+ }
106
+
107
+ export { RULES, ruleById, budget };
@@ -0,0 +1,15 @@
1
+ /** Independent calls issued one per turn instead of batched. */
2
+ export default {
3
+ id: 'batch-tool-calls',
4
+ pillar: 'tools',
5
+ severity: 2,
6
+ window: '7d',
7
+ detect({ tokens }) {
8
+ if ((tokens?.turnsWithTools ?? 0) < 30) return null;
9
+ if (tokens.callsPerTurn >= 1.3) return null;
10
+ return {
11
+ value: tokens.callsPerTurn,
12
+ evidence: `${tokens.callsPerTurn.toFixed(2)} tool calls per turn across ${tokens.turnsWithTools} turns — almost every call is its own round-trip`,
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,17 @@
1
+ /** Sessions repeatedly hitting the context ceiling. */
2
+ export default {
3
+ id: 'context-hygiene',
4
+ pillar: 'context',
5
+ severity: 3,
6
+ window: '7d',
7
+ detect({ context, volume }) {
8
+ const sessions = volume?.sessionCount ?? 0;
9
+ if (sessions < 3 || !context?.autoCompacts) return null;
10
+ const rate = context.autoCompacts / sessions;
11
+ if (rate < 0.8) return null;
12
+ return {
13
+ value: rate,
14
+ evidence: `${context.autoCompacts} automatic compactions across ${sessions} sessions — the window filled up and had to be summarised`,
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,15 @@
1
+ /** Wide searching in the main thread, where the results stay in context. */
2
+ export default {
3
+ id: 'delegate-wide-search',
4
+ pillar: 'tools',
5
+ severity: 1,
6
+ window: '7d',
7
+ detect({ tools }) {
8
+ if ((tools?.searchViaBash ?? 0) < 25) return null;
9
+ if ((tools?.delegations ?? 0) > 2) return null;
10
+ return {
11
+ value: tools.searchViaBash,
12
+ evidence: `${tools.searchViaBash} searches ran through Bash and none were delegated — every result is still sitting in your context`,
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Rule registry.
3
+ *
4
+ * Imported explicitly rather than discovered by scanning the directory: an
5
+ * implicit registry makes a broken rule silently absent, and a coach that
6
+ * quietly stops noticing things is worse than one that fails loudly.
7
+ */
8
+
9
+ import readBeforeEdit from './read-before-edit.mjs';
10
+ import batchToolCalls from './batch-tool-calls.mjs';
11
+ import verifyAfterChange from './verify-after-change.mjs';
12
+ import contextHygiene from './context-hygiene.mjs';
13
+ import promptSpecificity from './prompt-specificity.mjs';
14
+ import delegateWideSearch from './delegate-wide-search.mjs';
15
+ import permissionFriction from './permission-friction.mjs';
16
+ import retryStorm from './retry-storm.mjs';
17
+ import sessionSprawl from './session-sprawl.mjs';
18
+ import projectMemory from './project-memory.mjs';
19
+
20
+ export const RULES = [
21
+ readBeforeEdit, batchToolCalls, verifyAfterChange, contextHygiene,
22
+ promptSpecificity, delegateWideSearch, permissionFriction, retryStorm,
23
+ sessionSprawl, projectMemory,
24
+ ];
25
+
26
+ export function ruleById(id) {
27
+ return RULES.find((rule) => rule.id === id) ?? null;
28
+ }
@@ -0,0 +1,16 @@
1
+ /** The same permission prompt, over and over. Names the exact allowlist entry. */
2
+ export default {
3
+ id: 'permission-friction',
4
+ pillar: 'flow',
5
+ severity: 2,
6
+ window: '7d',
7
+ detect({ friction }) {
8
+ const worst = friction?.worstOffender;
9
+ if (!worst || worst.count < 6) return null;
10
+ return {
11
+ value: worst.count,
12
+ evidence: `"${worst.target}" stopped you ${worst.count} times, costing about ${Math.round((friction.blockedMs ?? 0) / 60_000)} minutes`,
13
+ action: `allow: ${worst.target}`,
14
+ };
15
+ },
16
+ };
@@ -0,0 +1,27 @@
1
+ /** A project worked in repeatedly with no CLAUDE.md to carry its context. */
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { loadProjects } from '../../collect/projects.mjs';
5
+
6
+ export default {
7
+ id: 'project-memory',
8
+ pillar: 'context',
9
+ severity: 1,
10
+ window: '30d',
11
+ detect({ volume }) {
12
+ const known = loadProjects();
13
+ for (const id of volume?.projects ?? []) {
14
+ const project = known[id];
15
+ if (!project?.root) continue;
16
+ // Checked at evaluation time, not recorded in the ledger: a file the user
17
+ // added yesterday should silence this rule today.
18
+ if (existsSync(join(project.root, 'CLAUDE.md'))) continue;
19
+ return {
20
+ value: id,
21
+ evidence: `"${project.name}" has no CLAUDE.md, so its background is re-established from scratch every session`,
22
+ action: `cd ${project.root} && claude, then /init`,
23
+ };
24
+ }
25
+ return null;
26
+ },
27
+ };
@@ -0,0 +1,15 @@
1
+ /** A high share of prompts that read as corrections. */
2
+ export default {
3
+ id: 'prompt-specificity',
4
+ pillar: 'prompts',
5
+ severity: 2,
6
+ window: '7d',
7
+ detect({ prompts }) {
8
+ if ((prompts?.count ?? 0) < 15) return null;
9
+ if (prompts.correctionRate <= 0.15) return null;
10
+ return {
11
+ value: prompts.correctionRate,
12
+ evidence: `${prompts.corrections} of ${prompts.count} prompts were corrections of the previous turn (median prompt: ${prompts.medianWords} words)`,
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,16 @@
1
+ /** Editing files the session never read. */
2
+ export default {
3
+ id: 'read-before-edit',
4
+ pillar: 'tools',
5
+ severity: 3,
6
+ window: '7d',
7
+ detect({ tools }) {
8
+ const edits = (tools?.readFirst ?? 0) + (tools?.blindEdit ?? 0);
9
+ if (edits < 8 || tools.readBeforeEditRate === null) return null;
10
+ if (tools.readBeforeEditRate >= 0.7) return null;
11
+ return {
12
+ value: tools.readBeforeEditRate,
13
+ evidence: `${tools.blindEdit} of your last ${edits} edits changed a file the session had not read`,
14
+ };
15
+ },
16
+ };
@@ -0,0 +1,22 @@
1
+ /** The same failing command run again with nothing changed. */
2
+ export default {
3
+ id: 'retry-storm',
4
+ pillar: 'feedback',
5
+ severity: 2,
6
+ window: '7d',
7
+ detect({ rework }) {
8
+ if ((rework?.stormCount ?? 0) < 2) return null;
9
+
10
+ const worst = [...(rework.storms ?? [])].sort((a, b) => b.streak - a.streak)[0];
11
+ if (!worst) {
12
+ return { value: rework.stormCount, evidence: `${rework.stormCount} commands were retried 3+ times without a change` };
13
+ }
14
+
15
+ const [tool, target = ''] = worst.key.split('|');
16
+ const label = `${tool} ${target}`.trim();
17
+ return {
18
+ value: rework.stormCount,
19
+ evidence: `"${label}" failed ${worst.streak} times in a row with nothing changed in between`,
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,15 @@
1
+ /** Long wall-clock sessions with little active time in them. */
2
+ export default {
3
+ id: 'session-sprawl',
4
+ pillar: 'flow',
5
+ severity: 1,
6
+ window: '7d',
7
+ detect({ time }) {
8
+ if (!time?.wallMs || time.wallMs < 4 * 3_600_000) return null;
9
+ if ((time.activeShare ?? 1) >= 0.25) return null;
10
+ return {
11
+ value: time.activeShare,
12
+ evidence: `${Math.round(time.wallMs / 3_600_000)}h of open sessions held ${Math.round(time.activeMs / 3_600_000)}h of actual work`,
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,16 @@
1
+ /** Runs of edits that never end in a test or build. */
2
+ export default {
3
+ id: 'verify-after-change',
4
+ pillar: 'feedback',
5
+ severity: 3,
6
+ window: '7d',
7
+ detect({ rework }) {
8
+ const runs = (rework?.verifiedRuns ?? 0) + (rework?.unverifiedRuns ?? 0);
9
+ if (runs < 5 || rework.verifyRate === null) return null;
10
+ if (rework.verifyRate >= 0.5) return null;
11
+ return {
12
+ value: rework.verifyRate,
13
+ evidence: `${rework.unverifiedRuns} of ${runs} runs of edits ended without a test, build or typecheck`,
14
+ };
15
+ },
16
+ };