@globalmpc/nomore 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Global MPC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # nomore
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Stop telling Claude Code the same thing twice.**
4
+
5
+ We read nearly 10,000 Claude Code GitHub issues about how the model behaves, and turned the most-reacted complaints
6
+ into 20 ready `CLAUDE.md` rules. Install them in 10 seconds. Then let `npx @globalmpc/nomore` find the things *you* keep
7
+ repeating.
8
+
9
+ ![nomore: repeated instructions become CLAUDE.md rules](assets/demo.gif)
10
+
11
+ ## Rule pack: 20 rules in 10 seconds
12
+
13
+ ```bash
14
+ curl -fsSL https://raw.githubusercontent.com/globalmpc/nomore/main/rules/CLAUDE.md -o nomore-rules.md
15
+ ```
16
+
17
+ Then add one line to your `CLAUDE.md`: `@nomore-rules.md` (or put both in `~/.claude/` for every project). Every rule
18
+ links to the issues behind it. Delete any you don't want; downloading again replaces the file.
19
+
20
+ Top 5:
21
+
22
+ - Don't add code comments unless they explain something non-obvious; match the comment density of the surrounding code, and stop when asked.
23
+ 1 issue · 252 reactions · [#65961](https://github.com/anthropics/claude-code/issues/65961)
24
+ - Don't use the word "load-bearing" or other recurring jargon; say what you mean in plain words.
25
+ 1 issue · 185 reactions · [#53454](https://github.com/anthropics/claude-code/issues/53454)
26
+ - Never run destructive git commands (`reset --hard`, `checkout -- .`, `clean -f`, `push --force`) or schedule them without explicit permission.
27
+ 1 issue · 121 reactions · [#40710](https://github.com/anthropics/claude-code/issues/40710)
28
+ - Keep typographic characters (curly quotes, em dashes, ellipses) exactly as written; don't replace them with ASCII.
29
+ 1 issue · 61 reactions · [#1599](https://github.com/anthropics/claude-code/issues/1599)
30
+ - Don't use the idiom "belt-and-suspenders"; vary phrasing and keep it literal.
31
+ 1 issue · 43 reactions · [#61462](https://github.com/anthropics/claude-code/issues/61462)
32
+
33
+ How they were chosen: every `anthropics/claude-code` issue labelled `area:model` or `data-loss`, fetched
34
+ 2026-10-24 (7,994 after dropping pull requests, duplicates and withdrawn reports). The 7,897 with words in their
35
+ title were grouped by title similarity ≥ 0.5 and ranked by Σ(1 + reactions); 20 behaviours a `CLAUDE.md` rule can
36
+ change were then picked by hand, from issues still open or closed as not planned. Each rule currently rests on one
37
+ issue and its reactions. Reproduce with `bash scripts/fetch-issues.sh && pnpm mine`.
38
+
39
+ **If a rule saved you a correction, star the repo so others find it.** Know a rule that belongs here?
40
+ [Propose it](../../issues/new?template=propose-a-rule.md).
41
+
42
+ ## CLI *(preview)*
43
+
44
+ Finds the instructions **you** keep repeating across your Claude Code sessions and suggests them as rules.
45
+
46
+ ```bash
47
+ npx @globalmpc/nomore # what you keep telling Claude (sessions active in the last 30 days)
48
+ npx @globalmpc/nomore --apply # pick suggestions one by one (yes / no / edit) and add them to CLAUDE.md
49
+ npx @globalmpc/nomore --project . # only this project; --apply then writes this project's CLAUDE.md
50
+ npx @globalmpc/nomore --card # a short summary to share: totals only
51
+ ```
52
+
53
+ **Preview, honestly:** most things people repeat are tasks ("commit", "continue"), not lasting preferences. On our
54
+ team's sessions about a third of the suggestions were real rules, so you choose what to keep; nothing is written
55
+ without your yes; rules you already have (including `@`-imported files like the rule pack) are recognised.
56
+ Filters are tuned for English, Korean and Indonesian. If nothing repeats yet, it points you to the
57
+ rule pack above.
58
+
59
+ Runs locally on Node 20+. It reads your Claude Code transcripts (respects `CLAUDE_CONFIG_DIR`), sends nothing
60
+ anywhere, and writes only the `CLAUDE.md` you choose plus a small log of adopted rules.
61
+
62
+ ## Contributing
63
+
64
+ Rules, wrong suggestions and code are all welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ pnpm install
70
+ pnpm test
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
package/dist/apply.js ADDED
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ const HEADING = "## nomore rules";
5
+ // Writes through a symlinked CLAUDE.md to its target. The temp file has a random name and is created exclusively,
6
+ // so nothing planted next to the target can redirect the write. An existing file keeps its exact permissions; a new
7
+ // one gets `newMode` narrowed by the umask; `forceMode` always applies (the adoption log is owner-only).
8
+ const writeAtomic = async (path, text, opts = {}) => {
9
+ const target = await realpath(path).catch(() => path);
10
+ await mkdir(dirname(target), { recursive: true });
11
+ const existing = await stat(target).then((st) => st.mode & 0o777, () => undefined);
12
+ const mode = opts.forceMode ?? existing ?? opts.newMode ?? 0o666;
13
+ const tmp = join(dirname(target), `.${basename(target)}.nomore-${randomUUID()}.tmp`);
14
+ await writeFile(tmp, text, { mode, flag: "wx" });
15
+ if (opts.forceMode !== undefined || existing !== undefined)
16
+ await chmod(tmp, mode); // the umask may have narrowed it
17
+ await rename(tmp, target);
18
+ };
19
+ const readOrEmpty = (path) => readFile(path, "utf8").catch((e) => {
20
+ if (e.code === "ENOENT")
21
+ return "";
22
+ throw e;
23
+ });
24
+ // Spec › Machine-supported decision: chosen rules go at the end of the "## nomore rules" section; a rule already
25
+ // written as a bullet anywhere in the file is skipped; existing content and line endings are kept; atomic write.
26
+ export async function applyRules(target, rules) {
27
+ const old = await readOrEmpty(target);
28
+ const eol = old.includes("\r\n") ? "\r\n" : "\n";
29
+ const lines = old === "" ? [] : old.replace(/\r?\n$/, "").split(/\r?\n/);
30
+ const present = new Set(lines.map((l) => l.trim()).filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim()));
31
+ const added = [];
32
+ const skipped = [];
33
+ for (const rule of rules.map((r) => r.trim()).filter(Boolean)) {
34
+ if (present.has(rule))
35
+ skipped.push(rule);
36
+ else
37
+ present.add(rule), added.push(rule);
38
+ }
39
+ if (added.length === 0)
40
+ return { added, skipped };
41
+ const bullets = added.map((r) => `- ${r}`);
42
+ const h = lines.findIndex((l) => l.trim() === HEADING);
43
+ if (h < 0) {
44
+ while (lines.length > 0 && lines.at(-1).trim() === "")
45
+ lines.pop();
46
+ lines.push(...(lines.length > 0 ? [""] : []), HEADING, "", ...bullets);
47
+ }
48
+ else {
49
+ const next = lines.findIndex((l, i) => i > h && /^#{1,6}\s/.test(l));
50
+ let at = next < 0 ? lines.length : next;
51
+ while (at > h + 1 && lines[at - 1].trim() === "")
52
+ at--;
53
+ lines.splice(at, 0, ...(at === h + 1 ? ["", ...bullets] : bullets));
54
+ }
55
+ await writeAtomic(target, lines.join(eol) + eol);
56
+ return { added, skipped };
57
+ }
58
+ export async function recordAdoption(configDir, entry, nowIso) {
59
+ const file = join(configDir, "nomore", "adopted.json");
60
+ const raw = await readOrEmpty(file);
61
+ let log = [];
62
+ if (raw !== "") {
63
+ try {
64
+ log = JSON.parse(raw);
65
+ if (!Array.isArray(log))
66
+ throw new Error("not a JSON array");
67
+ }
68
+ catch (e) {
69
+ throw new Error(`${file} can't be read (${e.message}); fix or remove it`);
70
+ }
71
+ }
72
+ log.push({ ...entry, adoptedAt: nowIso });
73
+ await writeAtomic(file, JSON.stringify(log, null, 2) + "\n", { forceMode: 0o600 });
74
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { parseArgs } from "node:util";
7
+ import { applyRules, recordAdoption } from "./apply.js";
8
+ import { runPipeline } from "./pipeline.js";
9
+ import { NOTHING_YET, renderCard, renderReport } from "./report.js";
10
+ import { resolveConfigDir } from "./sessions.js";
11
+ const USAGE = `Usage: nomore [--days <n>] [--project <path>] [--json | --card | --apply]
12
+
13
+ Finds what you keep telling Claude Code and suggests CLAUDE.md rules. Runs locally; writes only with --apply.
14
+ --days <n> sessions active in the last n days (default 30)
15
+ --project <path> only sessions from this project; also checks and writes its CLAUDE.md
16
+ --json machine-readable report
17
+ --card short summary to share: totals only, no suggestion text
18
+ --apply pick rules one by one and add them to CLAUDE.md`;
19
+ // Spec › Machine-supported decision: nothing is written unless the user picks a rule.
20
+ async function applyInteractively(report, target, configDir) {
21
+ if (report.suggestions.length === 0)
22
+ return console.log(NOTHING_YET), 0;
23
+ const rl = createInterface({ input: process.stdin });
24
+ const lines = rl[Symbol.asyncIterator]();
25
+ const ask = async (q) => {
26
+ process.stdout.write(q);
27
+ const r = await lines.next();
28
+ return r.done ? "q" : String(r.value).trim();
29
+ };
30
+ console.log(`Rules will be added to ${target}\n`);
31
+ const chosen = [];
32
+ for (const s of report.suggestions) {
33
+ const seen = `${s.count}× in ${s.sessions} sessions`;
34
+ const answer = (s.status === "ignored"
35
+ ? await ask(`Your rule "${s.matchedRule}" isn't followed (${seen}). Add a reworded rule? [e/n/q] `)
36
+ : await ask(`Add "${s.text}"? (${seen}) [y/n/e/q] `)).toLowerCase();
37
+ if (answer.startsWith("q"))
38
+ break;
39
+ if (answer.startsWith("y") && s.status === "new")
40
+ chosen.push({ groupId: s.id, text: s.text });
41
+ if (answer.startsWith("e")) {
42
+ const text = await ask("Rule: ");
43
+ if (text)
44
+ chosen.push({ groupId: s.id, text });
45
+ }
46
+ }
47
+ rl.close();
48
+ if (chosen.length === 0)
49
+ return console.log("No rules added."), 0;
50
+ const { added, skipped } = await applyRules(target, chosen.map((c) => c.text));
51
+ try {
52
+ for (const c of chosen.filter((c) => added.includes(c.text.trim())))
53
+ await recordAdoption(configDir, { ...c, text: c.text.trim(), target }, new Date().toISOString());
54
+ }
55
+ catch (e) {
56
+ console.error(`Rules were added, but the adoption log wasn't updated: ${e.message}`);
57
+ }
58
+ console.log(`✓ ${added.length} ${added.length === 1 ? "rule" : "rules"} added to ${target}` + (skipped.length ? ` (${skipped.length} already there)` : ""));
59
+ return 0;
60
+ }
61
+ async function main() {
62
+ let values;
63
+ try {
64
+ ({ values } = parseArgs({
65
+ options: {
66
+ days: { type: "string", default: "30" },
67
+ project: { type: "string" },
68
+ json: { type: "boolean" },
69
+ card: { type: "boolean" },
70
+ apply: { type: "boolean" },
71
+ help: { type: "boolean" },
72
+ },
73
+ }));
74
+ }
75
+ catch (e) {
76
+ console.error(`${e.message}\n\n${USAGE}`);
77
+ return 1;
78
+ }
79
+ if (values.help)
80
+ return console.log(USAGE), 0;
81
+ const days = Number(values.days);
82
+ if (!(days > 0))
83
+ return console.error(`--days must be a positive number\n\n${USAGE}`), 1;
84
+ if (values.apply && (values.json || values.card))
85
+ return console.error(`--apply can't be combined with --json or --card\n\n${USAGE}`), 1;
86
+ const configDir = resolveConfigDir(process.env, homedir());
87
+ const empty = { scannedSessions: 0, skippedLines: 0, corrections: 0, suggestions: [] };
88
+ if (!existsSync(join(configDir, "projects")))
89
+ return console.log(values.json ? JSON.stringify(empty, null, 2) : `No Claude Code sessions found in ${join(configDir, "projects")}`), 0;
90
+ const project = values.project && resolve(values.project);
91
+ const report = await runPipeline({ configDir, days, project, nowMs: Date.now() });
92
+ if (report.scannedSessions === 0)
93
+ return console.log(values.json ? JSON.stringify(report, null, 2) : `No Claude Code sessions found in ${join(configDir, "projects")} in the last ${days} days`), 0;
94
+ if (values.apply)
95
+ return applyInteractively(report, join(project ?? configDir, "CLAUDE.md"), configDir);
96
+ process.stdout.write(values.json ? JSON.stringify(report, null, 2) + "\n" : values.card ? renderCard(report) : renderReport(report));
97
+ return 0;
98
+ }
99
+ main().then((code) => (process.exitCode = code));
package/dist/detect.js ADDED
@@ -0,0 +1,123 @@
1
+ import { normalize, tokens } from "./normalize.js";
2
+ // Spec › Correction signals.
3
+ const LOOKAHEAD = 3;
4
+ const MAX_PHRASE = 400;
5
+ const STARTS = /^(no|nope|don'?t|do not|stop|wrong|not that|instead|undo|revert|i said|i told you)\b/i;
6
+ const CONTAINS = /\b(instead of|i said|i told you|don'?t|do not|never)\b/i;
7
+ const isCorrectionPhrase = (text) => {
8
+ const t = text.trim();
9
+ return t.length <= MAX_PHRASE && (STARTS.test(t) || CONTAINS.test(t.slice(0, 80)));
10
+ };
11
+ export function detectCorrections(events) {
12
+ const signals = new Map(); // index of a user_text event → its signals
13
+ const add = (i, s) => (signals.get(i) ?? signals.set(i, new Set()).get(i)).add(s);
14
+ const nextTyped = (i) => {
15
+ for (let j = i + 1; j <= i + LOOKAHEAD && j < events.length; j++)
16
+ if (events[j].kind === "user_text")
17
+ return j;
18
+ return -1;
19
+ };
20
+ const edits = [];
21
+ events.forEach((e, i) => {
22
+ if (e.kind === "tool_rejected" || e.kind === "user_marker") {
23
+ const j = nextTyped(i);
24
+ if (j >= 0)
25
+ add(j, e.kind === "tool_rejected" ? "rejected" : "interrupt");
26
+ }
27
+ if (e.kind === "user_text" && i > 0 && events[i - 1].kind.startsWith("assistant") && isCorrectionPhrase(e.text ?? ""))
28
+ add(i, "phrase");
29
+ if (e.kind === "assistant_tool_use" && e.tool === "Edit") {
30
+ const { file_path, old_string, new_string } = e.input ?? {};
31
+ if (edits.some((x) => x.file === file_path && x.old === new_string)) {
32
+ for (let j = i - 1; j >= 0; j--)
33
+ if (events[j].kind === "user_text") {
34
+ add(j, "revert");
35
+ break;
36
+ }
37
+ }
38
+ edits.push({ file: file_path, old: old_string });
39
+ }
40
+ });
41
+ return [...signals.keys()].sort((a, b) => a - b).map((i) => {
42
+ const e = events[i];
43
+ const text = e.text ?? "";
44
+ return { uuid: e.uuid, sessionId: e.sessionId, cwd: e.cwd, ts: e.ts, text, norm: normalize(text), signals: [...signals.get(i)].sort() };
45
+ });
46
+ }
47
+ // Spec › Repeated instructions: every typed message becomes clauses; clauses that can't be a lasting preference are
48
+ // dropped. Each clause keeps its message's correction signals for display.
49
+ const MAX_CLAUSE = 120;
50
+ const ACK = new Set("yes ok okay okey proceed go ahead continue sure thanks thank lets let start now then good great fine done next step right cool nice pls plz yea yeah yep keep going oke sip siap ya yuk lanjut terus teruskan ayo gas mantap aja saja silakan".split(" ")); // + Indonesian
51
+ const PLACEHOLDER = /^<(url|file|id|n)>$/;
52
+ const clean = (text) => text
53
+ .replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>/g, "\n")
54
+ .replace(/<([\w-]+)[^>]*>[\s\S]*?<\/\1>/g, "\n")
55
+ .replace(/<\/?[\w-]+[^>]*>/g, "\n");
56
+ // Pasted content is judged line by line, before clause splitting, so a log line can't leave a harmless-looking
57
+ // fragment ("…limit reached, retry later" → "retry later"). Spec › Repeated instructions.
58
+ const PASTED = [
59
+ /^\+/, // diff line
60
+ /[{}[\];]|=>|\(['"]/, // code punctuation
61
+ /^[\w$.]+\s*=\s*\S/, // name = value
62
+ /^[\w$.]+:\s*['"`]/, // name: 'value'
63
+ /^import\s+([\w$]+\s*$|.*\bfrom\b)/i, // import json / import x from 'y'
64
+ /^from\s+\S+\s+import\b/i,
65
+ /^(const|let|var)\s+\w+\s*=/,
66
+ /^(def|class|function)\s+\w+\s*[(:{]/,
67
+ /\w\([^)]*\)/, // call: openDb(), load(cfg)
68
+ /^(error|warning|warn|fatal|exception|traceback)\b/i,
69
+ /^at\s+\S+\s+\(/, // stack frame
70
+ /\berror:|exception|traceback|limit (reached|exceeded)/i,
71
+ /:$/, // heading that introduces pasted content
72
+ ];
73
+ const isIdentifier = (line) => /^[A-Za-z_$][\w$]*$/.test(line) && /_|[a-z][A-Z]/.test(line);
74
+ // A typed shell command: a known command and only ASCII arguments, short or with a flag or path, so sentences that
75
+ // start with a command name ("git push only after review", "npm 말고 pnpm 써") are kept.
76
+ const SHELL = /^(git|pnpm|npm|npx|yarn|node|python3?|pip3?|docker|kubectl|curl|wget|cd|ls|rm|mkdir|bash|sh|cargo|brew|gh|tsx|bun|deno|uv|ssh|sudo)(\s+[-\w./@:=~'"&|>]+)*\s*$/i;
77
+ const isShellCommand = (line) => SHELL.test(line) && (line.split(/\s+/).length <= 3 || /\s-{1,2}\w|[/\\]|\.\w/.test(line));
78
+ const isPasted = (line) => isIdentifier(line) || isShellCommand(line) || PASTED.some((re) => re.test(line));
79
+ // One-off requests and questions recur because the task recurs, not because they are lasting preferences.
80
+ const QUESTION = /^(what|whats|what's|how|why|when|where|which|who|can you|could you|would you|do you|is it|are you|should we|apa|apakah|bagaimana|gimana|berapa|kenapa|mengapa|kapan|di mana|dimana|siapa)\b/i;
81
+ const REQUEST = /^(review|check|update|provide|crawl|seed|recommend|let me|lets|let's|then|and|fix|commit|push|continue|implement|create|give|add|retry|redo|try|run|double|do (it|this|that|the)|lanjut|lanjutkan|coba|cek|periksa|perbaiki|benerin|buat|buatkan|bikin|jalankan|tambahkan|hapus|kirim)\b/i;
82
+ // A task verb can still open a lasting preference ("run tests before saying done"); these words keep it.
83
+ const PREFERENCE_CUE = /\b(always|never|every|each|before|after|first|only|myself|by default|from now on|one by one|step by step|instead|selalu|jangan|tanpa|hanya|cuma|sebelum|sesudah|setelah|mulai sekarang|sendiri)\b/i;
84
+ const FILLER = /^((pls|plz|please|yea|yeah|yep|yes|ok|okay|okey|now|so|just|also|tolong|mohon|oke|sip|siap|ya|yuk|silakan|ayo)\b[\s,]*)+/i;
85
+ // A negated instruction ("do not use npm") must never join its positive form ("use npm"); grouping runs per polarity.
86
+ const NEGATION = /\b(not|no|never|don'?t|doesn'?t|didn'?t|isn'?t|aren'?t|won'?t|can'?t|cannot|shouldn'?t|without|jangan|tidak|bukan|tanpa)\b|지\s?마|말고|않|못/i;
87
+ // A contrast tail ("use pnpm, not npm") qualifies a positive instruction; it doesn't negate it.
88
+ const CONTRAST = /^(not|instead of|rather than)\b/i;
89
+ export const isNegated = (text) => NEGATION.test(text.replace(/,\s*(not|instead of|rather than)\b.*$/i, ""));
90
+ // Korean (spec › Repeated instructions): Hangul has no \b word boundaries, so these match as substrings.
91
+ const KO_QUESTION = /뭐|무엇|뭔|어떻게|어떤|어디|언제|왜|얼마|몇|누구|어때|어떨까|(나요|까요|까|니|냐|습니까)[??.!~]*$/;
92
+ const KO_REQUEST = /(해\s?줘|주세요|줘|해\s?봐|하자|보자|부탁(해|합니다|드려요)?)[.!~]*$/;
93
+ const KO_PREFERENCE = /항상|절대|지\s?마|말고|없이|금지|대신|앞으로|매번|무조건|먼저|전에|후에|다음부터|직접|자세히|간단히|짧게|쉽게/;
94
+ const keep = (clause, question) => {
95
+ if (clause.length > MAX_CLAUSE || /^([-*#`|>]|\d+\.)/.test(clause))
96
+ return false;
97
+ const body = clause.replace(FILLER, "");
98
+ if (question || QUESTION.test(body) || (REQUEST.test(body) && !PREFERENCE_CUE.test(body)))
99
+ return false;
100
+ if ((KO_QUESTION.test(body) || KO_REQUEST.test(body)) && !KO_PREFERENCE.test(body))
101
+ return false;
102
+ const t = tokens(normalize(clause));
103
+ const words = t.filter((x) => !PLACEHOLDER.test(x) && x.length > 1);
104
+ return !t.includes("<url>") && words.length >= 2 && (t.length - words.length) * 2 <= t.length && !words.every((x) => ACK.has(x));
105
+ };
106
+ // Grouping tokens: plain plurals are singularised so "words" and "word" match.
107
+ export const clauseTokens = (text) => tokens(normalize(text)).map((t) => (t.length > 3 && t.endsWith("s") && !t.endsWith("ss") ? t.slice(0, -1) : t));
108
+ export function extractInstructions(events) {
109
+ const signals = new Map(detectCorrections(events).map((c) => [c.uuid, c.signals]));
110
+ return events.flatMap((e) => {
111
+ if (e.kind !== "user_text")
112
+ return [];
113
+ return clean(e.text ?? "")
114
+ .split("\n")
115
+ .filter((line) => !isPasted(line.trim()))
116
+ .join("\n")
117
+ .split(/\n+|(?<=[.;!??])\s+|,\s+|\s+(?:and|but)\s+/i)
118
+ .reduce((parts, p) => (CONTRAST.test(p.trim()) && parts.length ? [...parts.slice(0, -1), `${parts.at(-1)}, ${p.trim()}`] : [...parts, p]), [])
119
+ .map((raw, i) => ({ c: raw.trim().replace(/[.;!??]+$/, "").trim(), q: /[??]\s*$/.test(raw), i }))
120
+ .filter(({ c, q }) => keep(c, q))
121
+ .map(({ c, i }) => ({ uuid: `${e.uuid}:${i}`, sessionId: e.sessionId, cwd: e.cwd, ts: e.ts, text: c, norm: normalize(c), signals: signals.get(e.uuid) ?? [] }));
122
+ });
123
+ }
package/dist/group.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ export function idf(docs) {
3
+ const df = new Map();
4
+ for (const d of docs)
5
+ for (const t of new Set(d))
6
+ df.set(t, (df.get(t) ?? 0) + 1);
7
+ return new Map([...df].map(([t, n]) => [t, Math.log(1 + docs.length / n)]));
8
+ }
9
+ const sortedSet = (ts) => [...new Set(ts)].sort();
10
+ // Merge over sorted token sets: the sums run in token order, so the result is the same bit for bit both ways.
11
+ function simSorted(a, b, w) {
12
+ let inter = 0, union = 0, i = 0, j = 0;
13
+ while (i < a.length || j < b.length) {
14
+ if (a[i] === b[j]) {
15
+ const x = w.get(a[i]) ?? 0;
16
+ inter += x;
17
+ union += x;
18
+ i++, j++;
19
+ }
20
+ else if (j >= b.length || (i < a.length && a[i] < b[j]))
21
+ union += w.get(a[i++]) ?? 0;
22
+ else
23
+ union += w.get(b[j++]) ?? 0;
24
+ }
25
+ return union === 0 ? 0 : inter / union;
26
+ }
27
+ export function similarity(a, b, w) {
28
+ return simSorted(sortedSet(a), sortedSet(b), w);
29
+ }
30
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
31
+ // Items with no tokens (e.g. a bare "no") can't be compared or turned into a rule, so they are left out.
32
+ export function groupItems(items, tokensOf, threshold) {
33
+ const xs = items.map((x) => ({ x, t: sortedSet(tokensOf(x)) })).filter((e) => e.t.length > 0);
34
+ xs.sort((a, b) => byId(a.x, b.x));
35
+ const toks = xs.map((e) => e.t);
36
+ const w = idf(toks);
37
+ const parent = xs.map((_, i) => i);
38
+ const find = (i) => {
39
+ while (parent[i] !== i)
40
+ i = parent[i] = parent[parent[i]];
41
+ return i;
42
+ };
43
+ const push = (m, k, v) => (m.get(k) ?? m.set(k, []).get(k)).push(v);
44
+ // Identical token sets always join (similarity 1), so only one item per distinct set takes part in the join.
45
+ const byKey = new Map();
46
+ toks.forEach((ts, i) => push(byKey, ts.join(" "), i));
47
+ const uniq = [...byKey.values()].map((same) => {
48
+ same.forEach((i) => (parent[find(i)] = find(same[0])));
49
+ return same[0];
50
+ });
51
+ // Exact prefix filtering. With tokens in a global order (rarest first), any pair with weighted Jaccard ≥ t shares a
52
+ // token within each one's prefix: the leading tokens while the remaining weight could still hold t × its total
53
+ // weight. Common tokens end up in suffixes, so they no longer make every pair a candidate.
54
+ const order = (a, b) => w.get(b) - w.get(a) || (a < b ? -1 : a > b ? 1 : 0);
55
+ const index = new Map();
56
+ const prefixes = uniq.map((i) => {
57
+ const total = toks[i].reduce((s, t) => s + w.get(t), 0);
58
+ let rest = total;
59
+ const prefix = [];
60
+ for (const t of [...toks[i]].sort(order)) {
61
+ if (rest < threshold * total - 1e-9)
62
+ break;
63
+ prefix.push(t);
64
+ rest -= w.get(t);
65
+ }
66
+ return prefix;
67
+ });
68
+ prefixes.forEach((p, u) => p.forEach((t) => push(index, t, u)));
69
+ prefixes.forEach((p, u) => {
70
+ const cands = new Set();
71
+ for (const t of p)
72
+ for (const v of index.get(t))
73
+ if (v > u)
74
+ cands.add(v);
75
+ for (const v of cands) {
76
+ const [i, j] = [uniq[u], uniq[v]];
77
+ if (find(i) !== find(j) && simSorted(toks[i], toks[j], w) >= threshold)
78
+ parent[find(i)] = find(j);
79
+ }
80
+ });
81
+ const comps = new Map();
82
+ xs.forEach((_, i) => push(comps, find(i), i));
83
+ return [...comps.values()]
84
+ .map((idx) => {
85
+ // Score = summed similarity to the other members (pairs without a shared token score 0). Members with the same
86
+ // token set share a score, so it is computed once per distinct set: (copies − 1) × 1 + Σ copies × similarity.
87
+ // Rounded so float noise in the sums can't beat the ts tie-break.
88
+ const keys = new Map();
89
+ idx.forEach((i) => push(keys, toks[i].join(" "), i));
90
+ const reps = [...keys.values()];
91
+ const local = new Map();
92
+ reps.forEach((r, k) => toks[r[0]].forEach((t) => push(local, t, k)));
93
+ const keyScore = reps.map((r, k) => {
94
+ const others = new Set(toks[r[0]].flatMap((t) => local.get(t)).filter((m) => m !== k));
95
+ return Math.round(([...others].reduce((s, m) => s + reps[m].length * simSorted(toks[r[0]], toks[reps[m][0]], w), 0) + r.length - 1) * 1e9);
96
+ });
97
+ const scoreOf = new Map(reps.flatMap((r, k) => r.map((i) => [i, keyScore[k]])));
98
+ const score = (i) => scoreOf.get(i);
99
+ const best = idx
100
+ .map((i) => ({ i, s: score(i) }))
101
+ .sort((a, b) => b.s - a.s || (xs[a.i].x.ts < xs[b.i].x.ts ? -1 : xs[a.i].x.ts > xs[b.i].x.ts ? 1 : a.i - b.i))[0].i;
102
+ return {
103
+ id: createHash("sha1").update(toks[best].join(" ")).digest("hex").slice(0, 8),
104
+ members: idx.map((i) => xs[i].x),
105
+ representative: xs[best].x,
106
+ };
107
+ })
108
+ .sort((a, b) => byId(a.representative, b.representative));
109
+ }
package/dist/issues.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ export const Issue = z.object({
4
+ id: z.string(),
5
+ title: z.string(),
6
+ url: z.string(),
7
+ reactions: z.number(),
8
+ ts: z.string(),
9
+ labels: z.array(z.string()),
10
+ state: z.string(), // "open", or the close reason: "completed", "not_planned", …
11
+ });
12
+ // Only the fields we use; the body is never read.
13
+ const RawIssue = z.object({
14
+ number: z.number(),
15
+ title: z.string().nullable(),
16
+ html_url: z.string(),
17
+ created_at: z.string(),
18
+ labels: z.array(z.object({ name: z.string() })),
19
+ reactions: z.object({ total_count: z.number() }).optional(),
20
+ pull_request: z.unknown().optional(),
21
+ state: z.string(),
22
+ state_reason: z.string().nullable().optional(),
23
+ });
24
+ const EXCLUDED = new Set(["duplicate", "invalid"]);
25
+ // The whole title is a retraction: "Withdrawn by the author", "[withdrawn]", "[Bug] (withdrawn)",
26
+ // "[withdrawn] filed in error". A title that merely starts with the word ("Withdrawn API key …") is kept.
27
+ const WITHDRAWN = /^\s*(?:\[[^\]]{0,30}\]\s*)?[\s\p{P}]*withdrawn(?:[\s\p{P}]+(?:by (?:the )?author|filed in error)\b.*)?[\s\p{P}]*$/iu;
28
+ export function loadIssues(files) {
29
+ // First copy of each issue, with the labels of every copy, so an exclusion label in any file counts.
30
+ const byNumber = new Map();
31
+ for (const file of files) {
32
+ let raw;
33
+ try {
34
+ raw = z.array(RawIssue).parse(JSON.parse(readFileSync(file, "utf8")));
35
+ }
36
+ catch (e) {
37
+ throw new Error(`can't load issues from ${file}: ${e.message}`);
38
+ }
39
+ for (const r of raw) {
40
+ const seen = byNumber.get(r.number) ?? byNumber.set(r.number, { r, labels: new Set() }).get(r.number);
41
+ r.labels.forEach((l) => seen.labels.add(l.name));
42
+ }
43
+ }
44
+ return [...byNumber.keys()]
45
+ .sort((a, b) => a - b)
46
+ .map((n) => byNumber.get(n))
47
+ .filter(({ r, labels }) => r.pull_request === undefined && ![...labels].some((l) => EXCLUDED.has(l)) && !WITHDRAWN.test(r.title ?? ""))
48
+ .map(({ r, labels }) => Issue.parse({
49
+ id: String(r.number),
50
+ title: r.title ?? "",
51
+ url: r.html_url,
52
+ reactions: r.reactions?.total_count ?? 0,
53
+ ts: r.created_at,
54
+ labels: [...labels],
55
+ state: r.state === "open" ? "open" : (r.state_reason ?? r.state),
56
+ }));
57
+ }
58
+ // Strips short leading tags ("[BUG]", "[Feature Request]"); a title written entirely in brackets is kept.
59
+ export const cleanTitle = (title) => title.replace(/^(\s*\[[^\]]{0,30}\])+/, "").trim();
package/dist/mine.js ADDED
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import { groupItems } from "./group.js";
3
+ import { cleanTitle } from "./issues.js";
4
+ import { normalize, tokens } from "./normalize.js";
5
+ export const IssueGroup = z.object({
6
+ id: z.string(),
7
+ weight: z.number(),
8
+ issues: z.number(),
9
+ reactions: z.number(),
10
+ representative: z.object({ id: z.string(), title: z.string(), url: z.string() }),
11
+ top: z.array(z.object({ id: z.string(), title: z.string(), url: z.string(), reactions: z.number(), state: z.string() })).max(5),
12
+ });
13
+ // Spec › Part 0: titles join at ≥ 0.5; weight = Σ(1 + reactions); ranked by weight, ties by group id.
14
+ export function mineGroups(issues, threshold = 0.5) {
15
+ return groupItems(issues, (i) => tokens(normalize(cleanTitle(i.title))), threshold)
16
+ .map((g) => IssueGroup.parse({
17
+ id: g.id,
18
+ weight: g.members.reduce((s, i) => s + 1 + i.reactions, 0),
19
+ issues: g.members.length,
20
+ reactions: g.members.reduce((s, i) => s + i.reactions, 0),
21
+ representative: { id: g.representative.id, title: cleanTitle(g.representative.title), url: g.representative.url },
22
+ top: [...g.members]
23
+ .sort((a, b) => b.reactions - a.reactions || Number(a.id) - Number(b.id))
24
+ .slice(0, 5)
25
+ .map((i) => ({ id: i.id, title: i.title, url: i.url, reactions: i.reactions, state: i.state })),
26
+ }))
27
+ .sort((a, b) => b.weight - a.weight || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
28
+ }
29
+ // Titles are untrusted: escape markdown and HTML punctuation so they render as plain link text.
30
+ const text = (s) => s.replace(/\s+/g, " ").replace(/[\\`*_[\]()<>#!|~]/g, "\\$&");
31
+ export function renderTopGroups(gs, n) {
32
+ const rows = gs.slice(0, n).map((g, i) => {
33
+ const links = g.top.slice(0, 3).map((t) => `[#${t.id}](${t.url})`).join(" ");
34
+ return `| ${i + 1} | ${g.weight} | ${g.issues} | ${g.reactions} | [${text(g.representative.title)}](${g.representative.url}) | ${links} |`;
35
+ });
36
+ return ["| # | Weight | Issues | Reactions | Representative title | Top issues |", "|---|---|---|---|---|---|", ...rows, ""].join("\n");
37
+ }
@@ -0,0 +1,18 @@
1
+ const STOPWORDS = new Set(("a an the and or but if then so to of in on at by for from with about as into is are was were be been am " +
2
+ "it its this that these those i me my we us our you your he she they them their do does did don t s " +
3
+ "not no please here there just very can will would should could have has had").split(" "));
4
+ export function normalize(text) {
5
+ return text
6
+ .toLowerCase()
7
+ .replace(/https?:\/\/\S+/g, "<url>")
8
+ // name.ext needs a name of 2+ chars with a letter, so "e.g." and "24.x" stay text.
9
+ .replace(/\S*[/\\]\S*|\b(?=[\w-]*[a-z])[\w-]{2,}\.[a-z][a-z0-9]{0,4}\b/g, "<file>")
10
+ .replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b|\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*\d)[0-9a-f]{7,}\b/g, "<id>")
11
+ .replace(/\d+/g, "<n>")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ }
15
+ export function tokens(norm) {
16
+ // Placeholders stay whole ("<file>"), so they never match the word "file".
17
+ return (norm.match(/<(?:url|file|id|n)>|[\p{L}\p{N}]+/gu) ?? []).filter((t) => !STOPWORDS.has(t));
18
+ }
@@ -0,0 +1,47 @@
1
+ import { join, sep } from "node:path";
2
+ import { clauseTokens, extractInstructions, isNegated } from "./detect.js";
3
+ import { groupItems } from "./group.js";
4
+ import { rankGroups } from "./rank.js";
5
+ import { readRuleLines, toSuggestions } from "./rules.js";
6
+ import { listSessionFiles, parseSession } from "./sessions.js";
7
+ import { Report } from "./types.js";
8
+ const GROUP_THRESHOLD = 0.6; // spec › Grouping (clauses)
9
+ export async function runPipeline(opts) {
10
+ const files = await listSessionFiles(opts.configDir, { sinceMs: opts.nowMs - opts.days * 86_400_000, project: opts.project });
11
+ const corrections = [];
12
+ let skippedLines = 0;
13
+ for (const f of files) {
14
+ const s = await parseSession(f);
15
+ skippedLines += s.skippedLines;
16
+ corrections.push(...extractInstructions(s.events));
17
+ }
18
+ // Two project paths can share one encoded folder (/tmp/a-b, /tmp/a_b): keep only instructions from inside the project.
19
+ if (opts.project) {
20
+ const root = opts.project;
21
+ corrections.splice(0, corrections.length, ...corrections.filter((c) => c.cwd === root || c.cwd.startsWith(root + sep) || c.cwd.startsWith(root + "/")));
22
+ }
23
+ // Grouping runs per polarity, and negated clauses carry a "<not>" token, so ids never collide across the two.
24
+ const byPolarity = [corrections.filter((c) => !isNegated(c.text)), corrections.filter((c) => isNegated(c.text))];
25
+ const grouped = byPolarity.flatMap((cs, negated) => groupItems(cs.map((c) => ({ id: c.uuid, ts: c.ts, c })), (x) => [...clauseTokens(x.c.text), ...(negated ? ["<not>"] : [])], GROUP_THRESHOLD));
26
+ const groups = grouped.map((g) => {
27
+ const members = g.members.map((m) => m.c);
28
+ return {
29
+ id: g.id,
30
+ members,
31
+ representative: g.representative.c,
32
+ count: members.length,
33
+ sessions: new Set(members.map((m) => m.sessionId)).size,
34
+ projects: new Set(members.map((m) => m.cwd)).size,
35
+ days: new Set(members.map((m) => m.ts.slice(0, 10))).size,
36
+ lastSeen: members.map((m) => m.ts).sort().at(-1),
37
+ signals: [...new Set(members.flatMap((m) => m.signals))].sort(),
38
+ };
39
+ });
40
+ const rules = await readRuleLines([...(opts.project ? [join(opts.project, "CLAUDE.md")] : []), join(opts.configDir, "CLAUDE.md")]);
41
+ return Report.parse({
42
+ scannedSessions: files.length,
43
+ skippedLines,
44
+ corrections: corrections.length,
45
+ suggestions: toSuggestions(rankGroups(groups, opts.nowMs), rules),
46
+ });
47
+ }
package/dist/rank.js ADDED
@@ -0,0 +1,18 @@
1
+ const DAY_MS = 86_400_000;
2
+ const HALF_LIFE_DAYS = 14;
3
+ // Tasks repeat more often than preferences; a preference word in any clause triples a group's score. Korean has no
4
+ // \b word boundaries, so its words are matched as plain substrings.
5
+ const PREFERENCE = /\b(never|don'?t|do not|without|instead|always|only|stop|avoid|prefer|myself|too|selalu|jangan|tanpa|hanya|terlalu)\b|지 ?마|말고|없이|항상|절대|금지|대신|너무/i;
6
+ const BOOST = 3;
7
+ // Spec › Ranking: score = count × sessions × 0.5^(days since last / 14) × boost; shown if sessions ≥ 2.
8
+ export function rankGroups(gs, nowMs) {
9
+ return gs
10
+ .filter((g) => g.sessions >= 2)
11
+ .map((g) => ({
12
+ ...g,
13
+ signals: g.sessions >= 3 && !g.signals.includes("repeated") ? [...g.signals, "repeated"] : g.signals,
14
+ score: g.count * g.sessions * 0.5 ** ((nowMs - Date.parse(g.lastSeen)) / DAY_MS / HALF_LIFE_DAYS) *
15
+ (g.members.some((m) => PREFERENCE.test(m.text)) ? BOOST : 1),
16
+ }))
17
+ .sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
18
+ }
package/dist/report.js ADDED
@@ -0,0 +1,22 @@
1
+ // The public repo; change to the org's repo at handover (README's install link too).
2
+ export const REPO_URL = "https://github.com/globalmpc/nomore";
3
+ export const NOTHING_YET = `No instructions repeated across sessions yet.\nMeanwhile, start with the 20-rule pack: ${REPO_URL}/blob/main/rules/CLAUDE.md`;
4
+ const line = (s) => `● ${s.text}\n ${s.count}× in ${s.sessions} sessions, last ${s.lastSeen.slice(0, 10)}\n` +
5
+ s.examples.map((e) => ` “${e.length > 80 ? e.slice(0, 79) + "…" : e}”`).join("\n");
6
+ export function renderReport(r) {
7
+ const head = `nomore · ${r.scannedSessions} sessions · ${r.corrections} instructions` + (r.skippedLines ? ` · ${r.skippedLines} unreadable lines skipped` : "");
8
+ const fresh = r.suggestions.filter((s) => s.status === "new");
9
+ const ignored = r.suggestions.filter((s) => s.status === "ignored");
10
+ if (r.suggestions.length === 0)
11
+ return `${head}\n\n${NOTHING_YET}\n`;
12
+ return [
13
+ head,
14
+ ...(fresh.length ? ["", "Repeated instructions", "", ...fresh.map(line)] : []),
15
+ ...(ignored.length ? ["", "Rules you have, but the agent ignores", "", ...ignored.map((s) => `${line(s)}\n rule: ${s.matchedRule}`)] : []),
16
+ "",
17
+ ].join("\n");
18
+ }
19
+ // Safe to share: totals only. Suggestion text can hold private words, so it never appears here.
20
+ export function renderCard(r) {
21
+ return `nomore: ${r.scannedSessions} sessions, ${r.corrections} instructions, ${r.suggestions.length} repeated.\n`;
22
+ }
package/dist/rules.js ADDED
@@ -0,0 +1,68 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { isNegated } from "./detect.js";
5
+ import { idf, similarity } from "./group.js";
6
+ import { normalize, tokens } from "./normalize.js";
7
+ import { Suggestion } from "./types.js";
8
+ const RULE_MATCH = 0.3; // spec › Rule check
9
+ // Bullet or sentence lines of the rules files; headings, blanks and missing files are skipped. A line "@path"
10
+ // imports another file in its place (Claude Code's CLAUDE.md imports: relative to the file, or ~/), once each.
11
+ export async function readRuleLines(paths) {
12
+ const seen = new Set();
13
+ const read = async (path) => {
14
+ if (seen.has(path))
15
+ return [];
16
+ seen.add(path);
17
+ const text = await readFile(path, "utf8").catch(() => "");
18
+ const out = [];
19
+ for (const raw of text.split("\n")) {
20
+ const line = raw.trim();
21
+ const imp = line.match(/^@(\S+)$/);
22
+ if (imp)
23
+ out.push(...(await read(imp[1].startsWith("~/") ? join(homedir(), imp[1].slice(2)) : resolve(dirname(path), imp[1]))));
24
+ else if (line !== "" && !line.startsWith("#"))
25
+ out.push(line.replace(/^([-*]|\d+\.)\s+/, ""));
26
+ }
27
+ return out;
28
+ };
29
+ const lines = [];
30
+ for (const p of paths)
31
+ lines.push(...(await read(resolve(p))));
32
+ return lines;
33
+ }
34
+ const template = (text) => {
35
+ const t = text.trim().replace(/^(no|nope)[,.!]*\s+|^don'?t do that[,.!]*\s+/i, "");
36
+ return t.charAt(0).toUpperCase() + t.slice(1);
37
+ };
38
+ export function toSuggestions(groups, ruleLines) {
39
+ const ruleToks = ruleLines.map((l) => tokens(normalize(l)));
40
+ const repToks = groups.map((g) => tokens(g.representative.norm));
41
+ const w = idf([...ruleToks, ...repToks]);
42
+ return groups.map((g, gi) => {
43
+ let best = -1, bestSim = 0;
44
+ const negated = isNegated(g.representative.text);
45
+ ruleToks.forEach((rt, ri) => {
46
+ if (isNegated(ruleLines[ri]) !== negated)
47
+ return; // "Use npm" doesn't cover "don't use npm"
48
+ const s = similarity(repToks[gi], rt, w);
49
+ if (s > bestSim)
50
+ [best, bestSim] = [ri, s];
51
+ });
52
+ const ignored = best >= 0 && bestSim >= RULE_MATCH;
53
+ return Suggestion.parse({
54
+ id: g.id,
55
+ text: template(g.representative.text),
56
+ status: ignored ? "ignored" : "new",
57
+ ...(ignored ? { matchedRule: ruleLines[best] } : {}),
58
+ count: g.count,
59
+ sessions: g.sessions,
60
+ projects: g.projects,
61
+ days: g.days,
62
+ score: g.score,
63
+ lastSeen: g.lastSeen,
64
+ examples: [...new Set(g.members.map((m) => m.text.trim()))].slice(0, 3),
65
+ signals: g.signals,
66
+ });
67
+ });
68
+ }
@@ -0,0 +1,95 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ export const resolveConfigDir = (env, home) => env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
6
+ // Claude Code names a project's folder after its path with every non-alphanumeric character replaced by "-".
7
+ const encodeProject = (path) => path.replace(/[^a-zA-Z0-9]/g, "-");
8
+ export async function listSessionFiles(configDir, opts) {
9
+ const root = join(configDir, "projects");
10
+ const dirs = await readdir(root).catch(() => []);
11
+ const files = [];
12
+ for (const d of dirs.sort()) {
13
+ // Sessions started in a subfolder live in their own encoded folder ("-repo-web"); lookalikes ("-repo-other") are
14
+ // removed later by the cwd check in runPipeline.
15
+ if (opts.project !== undefined && d !== encodeProject(opts.project) && !d.startsWith(encodeProject(opts.project) + "-"))
16
+ continue;
17
+ const names = await readdir(join(root, d)).catch(() => []);
18
+ for (const f of names.filter((n) => n.endsWith(".jsonl")).sort()) {
19
+ const path = join(root, d, f);
20
+ if ((await stat(path)).mtimeMs >= opts.sinceMs)
21
+ files.push(path);
22
+ }
23
+ }
24
+ return files;
25
+ }
26
+ const REJECTED = "The user doesn't want to proceed";
27
+ const INTERRUPT = "[Request interrupted by user";
28
+ // IDE context (<ide_opened_file>…), command tags and command output arrive as complete tag blocks before the text.
29
+ const stripLeadingTags = (s) => s.replace(/^\s*(<([\w-]+)[^>]*>[\s\S]*?<\/\2>\s*)+/, "");
30
+ const resultText = (c) => typeof c === "string" ? c : Array.isArray(c) ? c.map((i) => (typeof i?.text === "string" ? i.text : "")).join("") : "";
31
+ // One record → zero or more events (spec › Inputs, › Not a user message). Unknown shapes yield nothing.
32
+ function toEvents(r) {
33
+ if (r.isSidechain === true || typeof r.uuid !== "string")
34
+ return [];
35
+ // sdk-cli, sdk-ts, sdk-py: Claude Code run by a script, whose "user" messages are program-written templates.
36
+ if (typeof r.entrypoint === "string" && r.entrypoint.startsWith("sdk"))
37
+ return [];
38
+ const base = { uuid: r.uuid, sessionId: String(r.sessionId ?? ""), cwd: String(r.cwd ?? ""), ts: String(r.timestamp ?? "") };
39
+ const content = r.message?.content;
40
+ if (r.type === "user") {
41
+ const injected = r.isMeta === true || r.isCompactSummary === true; // compaction summaries are written by Claude Code
42
+ const typed = (raw) => {
43
+ const text = stripLeadingTags(raw).trim();
44
+ return injected || text === "" || text.startsWith("<") ? [] : [{ ...base, kind: "user_text", text }];
45
+ };
46
+ if (typeof content === "string")
47
+ return typed(content);
48
+ if (!Array.isArray(content))
49
+ return [];
50
+ const signals = content.flatMap((i) => {
51
+ if (i?.type === "text" && typeof i.text === "string" && i.text.startsWith(INTERRUPT))
52
+ return [{ ...base, kind: "user_marker", text: i.text }];
53
+ if (i?.type === "tool_result" && i.is_error === true && resultText(i.content).startsWith(REJECTED))
54
+ return [{ ...base, kind: "tool_rejected" }];
55
+ return [];
56
+ });
57
+ // IDE extensions and pasted images store a typed message as blocks: join its plain text blocks.
58
+ if (content.some((i) => i?.type === "tool_result"))
59
+ return signals;
60
+ const text = content
61
+ .filter((i) => i?.type === "text" && typeof i.text === "string" && !i.text.startsWith(INTERRUPT) && !i.text.trimStart().startsWith("<"))
62
+ .map((i) => i.text)
63
+ .join("\n");
64
+ return [...signals, ...typed(text)];
65
+ }
66
+ if (r.type === "assistant" && Array.isArray(content)) {
67
+ return content.flatMap((i) => {
68
+ if (i?.type === "tool_use" && typeof i.name === "string")
69
+ return [{ ...base, kind: "assistant_tool_use", tool: i.name, input: i.input ?? {} }];
70
+ if (i?.type === "text" && typeof i.text === "string")
71
+ return [{ ...base, kind: "assistant_text", text: i.text }];
72
+ return [];
73
+ });
74
+ }
75
+ return [];
76
+ }
77
+ export async function parseSession(file) {
78
+ const events = [];
79
+ let skippedLines = 0;
80
+ for await (const line of createInterface({ input: createReadStream(file), crlfDelay: Infinity })) {
81
+ if (line.trim() === "")
82
+ continue;
83
+ let r;
84
+ try {
85
+ r = JSON.parse(line);
86
+ }
87
+ catch {
88
+ skippedLines++; // a session still being written ends in a partial line
89
+ continue;
90
+ }
91
+ if (r && typeof r === "object")
92
+ events.push(...toEvents(r));
93
+ }
94
+ return { events, skippedLines };
95
+ }
package/dist/types.js ADDED
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ export const Event = z.object({
3
+ kind: z.enum(["user_text", "user_marker", "tool_rejected", "assistant_tool_use", "assistant_text"]),
4
+ uuid: z.string(),
5
+ sessionId: z.string(),
6
+ cwd: z.string(),
7
+ ts: z.string(),
8
+ text: z.string().optional(),
9
+ tool: z.string().optional(),
10
+ input: z.record(z.string(), z.unknown()).optional(),
11
+ });
12
+ export const Signal = z.enum(["rejected", "interrupt", "phrase", "revert", "repeated"]);
13
+ export const Correction = z.object({
14
+ uuid: z.string(),
15
+ sessionId: z.string(),
16
+ cwd: z.string(),
17
+ ts: z.string(),
18
+ text: z.string(),
19
+ norm: z.string(),
20
+ signals: z.array(Signal),
21
+ });
22
+ export const Group = z.object({
23
+ id: z.string(),
24
+ members: z.array(Correction),
25
+ representative: Correction,
26
+ count: z.number(),
27
+ sessions: z.number(),
28
+ projects: z.number(),
29
+ days: z.number(),
30
+ lastSeen: z.string(),
31
+ score: z.number(),
32
+ signals: z.array(Signal),
33
+ });
34
+ export const Suggestion = z.object({
35
+ id: z.string(),
36
+ text: z.string(),
37
+ status: z.enum(["new", "ignored"]),
38
+ matchedRule: z.string().optional(),
39
+ count: z.number(),
40
+ sessions: z.number(),
41
+ projects: z.number(),
42
+ days: z.number(),
43
+ score: z.number(),
44
+ lastSeen: z.string(),
45
+ examples: z.array(z.string()).max(3),
46
+ signals: z.array(Signal),
47
+ });
48
+ export const Report = z.object({
49
+ scannedSessions: z.number(),
50
+ skippedLines: z.number(),
51
+ corrections: z.number(),
52
+ suggestions: z.array(Suggestion),
53
+ });
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@globalmpc/nomore",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Stop telling Claude Code the same thing twice: a CLAUDE.md rule pack from ~10k issues, and a local tool that finds what you keep repeating.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude",
8
+ "claude-md",
9
+ "ai-agents",
10
+ "developer-tools",
11
+ "cli"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/globalmpc/nomore.git"
17
+ },
18
+ "homepage": "https://github.com/globalmpc/nomore#readme",
19
+ "bugs": "https://github.com/globalmpc/nomore/issues",
20
+ "type": "module",
21
+ "bin": {
22
+ "nomore": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "vitest run",
32
+ "typecheck": "tsc --noEmit",
33
+ "build": "tsc -p tsconfig.build.json",
34
+ "mine": "tsx scripts/mine-issues.ts"
35
+ },
36
+ "dependencies": {
37
+ "zod": "^4.6.7"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^26.6.5",
41
+ "tsx": "^4.23.15",
42
+ "typescript": "^7.0.2",
43
+ "vitest": "^5.0.5"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }