@globalmpc/nomore 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,37 +1,86 @@
1
1
  # nomore
2
2
 
3
- **Stop telling Claude Code the same thing twice.**
3
+ [![npm](https://img.shields.io/npm/v/@globalmpc/nomore)](https://www.npmjs.com/package/@globalmpc/nomore)
4
+ [![ci](https://github.com/globalmpc/nomore/actions/workflows/ci.yml/badge.svg)](https://github.com/globalmpc/nomore/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
6
 
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.
7
+ **Stop telling Claude Code the same thing twice.**
8
8
 
9
9
  ![nomore: repeated instructions become CLAUDE.md rules](assets/demo.gif)
10
10
 
11
- ## Rule pack: 20 rules in 10 seconds
11
+ **nomore finds the instructions you keep repeating to Claude Code and, with your yes, turns them into `CLAUDE.md`
12
+ rules, so they survive compaction and new sessions.** Free and open source, by [GlobalMPC](https://github.com/globalmpc).
13
+ It also reads OpenAI Codex CLI sessions and writes `AGENTS.md` rules ([Codex](#codex)).
14
+
15
+ ## What you get
16
+
17
+ - **Stop repeating yourself.** See the instructions you've repeated in at least two sessions (last 30 days by
18
+ default), with counts and when you last said them.
19
+ - **Rules that survive compaction.** Turn the ones you pick into `CLAUDE.md` rules, loaded in every session.
20
+ - **Catch ignored rules.** It flags rules already in your `CLAUDE.md` that you still keep repeating, a sign Claude
21
+ isn't following them.
22
+ - **A head start.** 20 ready rules from over 8,000 Claude Code GitHub issues: what users complain about most.
23
+ - **Private by default.** Runs on your machine, sends nothing anywhere, uses no LLM, and writes only what you approve
24
+ ([what it reads and writes](#what-it-reads-and-writes)).
25
+
26
+ ## Quick start
12
27
 
13
28
  ```bash
14
- curl -fsSL https://raw.githubusercontent.com/globalmpc/nomore/main/rules/CLAUDE.md -o nomore-rules.md
29
+ npx @globalmpc/nomore # what you keep telling Claude (sessions active in the last 30 days)
30
+ npx @globalmpc/nomore --apply # pick suggestions one by one (yes / no / edit) and add them to CLAUDE.md
15
31
  ```
16
32
 
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.
33
+ Add the 20-rule pack to your global `CLAUDE.md` (the file `--apply` writes to; use `$CLAUDE_CONFIG_DIR` instead of
34
+ `~/.claude` if you set it) in one line:
35
+
36
+ ```bash
37
+ (echo; curl -fsSL https://raw.githubusercontent.com/globalmpc/nomore/main/rules/CLAUDE.md) >> ~/.claude/CLAUDE.md
38
+ ```
39
+
40
+ Using Codex? See [Codex](#codex) for the same commands and where to put the pack.
41
+
42
+ ![What you type in chat doesn't survive compaction. CLAUDE.md does.](assets/compaction.png)
43
+
44
+ ## The rule pack: 20 rules from over 8,000 issues
45
+
46
+ ![Top 10 Claude Code behaviours by reactions](assets/chart.png)
47
+
48
+ What the 20 rules stop:
49
+
50
+ | Theme | Examples |
51
+ |---|---|
52
+ | **Touch only what you were asked to** | unrequested code comments, edits in plan mode, changing a test just to make it pass |
53
+ | **Talk plainly** | "load-bearing", "belt-and-suspenders", answering in a different language than yours |
54
+ | **Never destroy work** | `reset --hard`, `push --force`, invented tool output |
55
+ | **Keep your text as written** | curly quotes and em dashes turned into ASCII, transliterated non-ASCII letters |
56
+ | **Work in the open** | looping instead of asking, long silent tool runs, claims about code it hasn't read |
19
57
 
20
58
  Top 5:
21
59
 
22
60
  - Don't add code comments unless they explain something non-obvious; match the comment density of the surrounding code, and stop when asked.
23
61
  1 issue · 252 reactions · [#65961](https://github.com/anthropics/claude-code/issues/65961)
24
62
  - 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)
63
+ 1 issue · 186 reactions · [#53454](https://github.com/anthropics/claude-code/issues/53454)
26
64
  - Never run destructive git commands (`reset --hard`, `checkout -- .`, `clean -f`, `push --force`) or schedule them without explicit permission.
27
65
  1 issue · 121 reactions · [#40710](https://github.com/anthropics/claude-code/issues/40710)
28
66
  - Keep typographic characters (curly quotes, em dashes, ellipses) exactly as written; don't replace them with ASCII.
29
67
  1 issue · 61 reactions · [#1599](https://github.com/anthropics/claude-code/issues/1599)
30
68
  - 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)
69
+ 1 issue · 44 reactions · [#61462](https://github.com/anthropics/claude-code/issues/61462)
70
+
71
+ All 20, each linked to its issue: [`rules/CLAUDE.md`](rules/CLAUDE.md). To keep the pack in its own file (downloading
72
+ again replaces it cleanly), save it next to your `CLAUDE.md` and import it with one line, `@nomore-rules.md`:
73
+
74
+ ```bash
75
+ curl -fsSL https://raw.githubusercontent.com/globalmpc/nomore/main/rules/CLAUDE.md -o nomore-rules.md
76
+ ```
77
+
78
+ Put both in your Claude config folder (`~/.claude/`, or `CLAUDE_CONFIG_DIR`) to use them in every project. Delete any
79
+ rule you don't want. Run the one-line append above once: a second run adds the pack again under a second
80
+ `## nomore rules` heading. Downloading `nomore-rules.md` again is safe, because it replaces the file.
32
81
 
33
82
  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
83
+ 2026-10-31 (8,196 after dropping pull requests, duplicates and withdrawn reports). The 8,096 with words in their
35
84
  title were grouped by title similarity ≥ 0.5 and ranked by Σ(1 + reactions); 20 behaviours a `CLAUDE.md` rule can
36
85
  change were then picked by hand, from issues still open or closed as not planned. Each rule currently rests on one
37
86
  issue and its reactions. Reproduce with `bash scripts/fetch-issues.sh && pnpm mine`.
@@ -39,9 +88,10 @@ issue and its reactions. Reproduce with `bash scripts/fetch-issues.sh && pnpm mi
39
88
  **If a rule saved you a correction, star the repo so others find it.** Know a rule that belongs here?
40
89
  [Propose it](../../issues/new?template=propose-a-rule.md).
41
90
 
42
- ## CLI *(preview)*
91
+ ## How the CLI works
43
92
 
44
- Finds the instructions **you** keep repeating across your Claude Code sessions and suggests them as rules.
93
+ It reads your Claude Code transcripts, finds the instructions you repeat, groups similar wordings, and checks them
94
+ against the rules you already have.
45
95
 
46
96
  ```bash
47
97
  npx @globalmpc/nomore # what you keep telling Claude (sessions active in the last 30 days)
@@ -50,14 +100,64 @@ npx @globalmpc/nomore --project . # only this project; --apply then writes thi
50
100
  npx @globalmpc/nomore --card # a short summary to share: totals only
51
101
  ```
52
102
 
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.
103
+ **Preview, honestly:** most things people repeat are tasks ("commit", "continue"), not lasting preferences, so not
104
+ every suggestion is a rule. You choose what to keep; nothing is written without your yes; rules you already have
105
+ (including `@`-imported files like the rule pack) are recognised.
106
+ Filters are tuned for English, Korean and Indonesian. If it finds sessions but nothing repeats yet, it points you to
107
+ the rule pack above.
108
+
109
+ ### Codex
110
+
111
+ ```bash
112
+ npx @globalmpc/nomore --agent codex # same report, from your Codex sessions
113
+ npx @globalmpc/nomore --agent codex --apply # add picked rules to the AGENTS.md Codex reads
114
+ npx @globalmpc/nomore --agent codex --project . # only this project; --apply then writes this project's AGENTS.md
115
+ ```
116
+
117
+ Codex keeps its data in `CODEX_HOME` (default `~/.codex`). Add the rule pack to your global `AGENTS.md` (use
118
+ `$CODEX_HOME` instead of `~/.codex` if you set it):
119
+
120
+ ```bash
121
+ (echo; curl -fsSL https://raw.githubusercontent.com/globalmpc/nomore/main/rules/AGENTS.md) >> ~/.codex/AGENTS.md
122
+ ```
58
123
 
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.
124
+ `AGENTS.md` has no `@`-imports, so the "save it next to your file and import it" trick above is Claude-only; for Codex,
125
+ append the text. If you have a non-blank `AGENTS.override.md` in the same folder, Codex reads that instead of `AGENTS.md`: append to it instead. Append it once; to update it, delete the old `## nomore rules` section first, keeping any rules you
126
+ added with `--apply` (they go into that same section; a second append creates a second heading, and `--apply` writes
127
+ into the first one). Delete any rule you don't want.
128
+
129
+ How the Codex pack was chosen: every `openai/codex` issue labelled `model-behavior`, `tool-calls`, `context`, `agent`
130
+ or `subagent`, fetched 2026-10-31 (6,503 after dropping pull requests, duplicates and withdrawn reports; 6,499 with
131
+ words in their title), grouped by title similarity ≥ 0.5 and ranked by Σ(1 + reactions); 20 behaviours an `AGENTS.md`
132
+ rule can change were then picked by hand. Each rule's issue is labelled `bug` or `model-behavior` (not `enhancement`),
133
+ and is still open or closed as not planned. Each rule rests on one issue and its reactions. Unlike the Claude pack, it
134
+ has no rule against destructive git commands: no eligible Codex issue reports it. Reproduce with
135
+ `bash scripts/fetch-issues.sh --agent codex && pnpm mine --agent codex`.
136
+
137
+ Not in the first Codex version:
138
+
139
+ - It checks the `AGENTS.md` in `CODEX_HOME` (and in the `--project` folder with `--project`), not the ones in the
140
+ folders between your repo root and your working directory.
141
+ - It doesn't model `project_doc_max_bytes` (by default Codex cuts project docs at 32 KiB) or
142
+ `project_doc_fallback_filenames`.
143
+ - No revert or rejected-edit hints yet: for Codex, nomore reads what you typed and when you interrupted, not Codex's
144
+ edits or approvals.
145
+
146
+ ### What it reads and writes
147
+
148
+ Runs locally on Node 20+ and makes no network requests (the package has no network code).
149
+
150
+ - **Reads:** your Claude Code transcripts (`~/.claude/projects/*/*.jsonl`, or the same under `CLAUDE_CONFIG_DIR` if
151
+ you set it) and your `CLAUDE.md` files, including the files they `@`-import.
152
+ - **Writes, only with `--apply` and only after your yes:** the `CLAUDE.md` you chose (via a temp file and rename, so
153
+ it is never half-written) and a log of adopted rules, `nomore/adopted.json` in that same config folder (readable
154
+ only by you).
155
+ - **With `--agent codex`, reads:** `sessions/**/rollout-*.jsonl` under `CODEX_HOME` (default `~/.codex`) and the
156
+ `AGENTS.md` / `AGENTS.override.md` there (and in the `--project` folder with `--project`). Only sessions you typed in: subagent threads and `codex exec` runs are
157
+ skipped. **Writes, only with `--apply` and after your yes:** the file Codex reads in that folder
158
+ (`AGENTS.override.md` if it has content, else `AGENTS.md`; the `--project` folder with `--project`), through a temp
159
+ file and rename, and `nomore/adopted.json` under `CODEX_HOME` (readable only by you).
160
+ - `--card` prints totals only, never message text.
61
161
 
62
162
  ## Contributing
63
163
 
package/dist/cli.js CHANGED
@@ -5,27 +5,48 @@ import { join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { parseArgs } from "node:util";
7
7
  import { applyRules, recordAdoption } from "./apply.js";
8
+ import { codexRuleFile, resolveCodexHome } from "./codex.js";
8
9
  import { runPipeline } from "./pipeline.js";
9
- import { NOTHING_YET, renderCard, renderReport } from "./report.js";
10
+ import { NOTHING_YET, NOTHING_YET_CODEX, RULE_PACK, RULE_PACK_CODEX, renderCard, renderReport, skippedFilesNote } from "./report.js";
10
11
  import { resolveConfigDir } from "./sessions.js";
11
- const USAGE = `Usage: nomore [--days <n>] [--project <path>] [--json | --card | --apply]
12
+ const USAGE = `Usage: nomore [--days <n>] [--agent claude|codex] [--project <path>] [--json | --card | --apply]
12
13
 
13
- Finds what you keep telling Claude Code and suggests CLAUDE.md rules. Runs locally; writes only with --apply.
14
+ Finds what you keep telling Claude Code (or Codex) and suggests CLAUDE.md (or AGENTS.md) rules. Runs locally; writes only with --apply.
14
15
  --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
+ --agent <name> claude (default) or codex; codex reads $CODEX_HOME/sessions (default ~/.codex)
17
+ --project <path> only sessions from this project; also checks and writes its CLAUDE.md (AGENTS.md with --agent codex)
16
18
  --json machine-readable report
17
19
  --card short summary to share: totals only, no suggestion text
18
- --apply pick rules one by one and add them to CLAUDE.md`;
20
+ --apply pick rules one by one and add them to CLAUDE.md (AGENTS.md with --agent codex)`;
21
+ // One readable reason for a file error, instead of a stack trace with temp-file paths.
22
+ const reason = (e) => {
23
+ const code = e.code;
24
+ return code === "EACCES" || code === "EPERM" ? "permission denied" : code === "EROFS" ? "read-only file system" : code === "EISDIR" ? "it is a folder" : e.message;
25
+ };
26
+ // With --project, name the project (a typo shouldn't read as "you have no sessions at all"), and say if it doesn't exist.
27
+ const noSessions = (name, sessionsDir, days, pack, project) => project
28
+ ? `No ${name} sessions for ${project} in the last ${days} days.\n${existsSync(project) ? "" : "That folder doesn't exist; check the path.\n"}${pack}`
29
+ : `No ${name} sessions found in ${sessionsDir} in the last ${days} days\n${pack}`;
19
30
  // Spec › Machine-supported decision: nothing is written unless the user picks a rule.
20
- async function applyInteractively(report, target, configDir) {
31
+ async function applyInteractively(report, target, configDir, nothingYet) {
32
+ if (report.skippedFiles)
33
+ console.log(skippedFilesNote(report));
21
34
  if (report.suggestions.length === 0)
22
- return console.log(NOTHING_YET), 0;
35
+ return console.log(nothingYet), 0;
23
36
  const rl = createInterface({ input: process.stdin });
24
37
  const lines = rl[Symbol.asyncIterator]();
38
+ // undefined means input ended: every caller treats it as quit, so it is never taken as an answer or rule text.
39
+ let answered = false;
25
40
  const ask = async (q) => {
26
41
  process.stdout.write(q);
27
42
  const r = await lines.next();
28
- return r.done ? "q" : String(r.value).trim();
43
+ const a = r.done ? undefined : String(r.value).trim();
44
+ // A terminal echoes the answer itself; piped input doesn't, so echo it and end the line (also on EOF).
45
+ if (!process.stdin.isTTY)
46
+ process.stdout.write(`${a ?? ""}\n`);
47
+ if (a !== undefined)
48
+ answered = true;
49
+ return a;
29
50
  };
30
51
  console.log(`Rules will be added to ${target}\n`);
31
52
  const chosen = [];
@@ -33,21 +54,33 @@ async function applyInteractively(report, target, configDir) {
33
54
  const seen = `${s.count}× in ${s.sessions} sessions`;
34
55
  const answer = (s.status === "ignored"
35
56
  ? 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"))
57
+ : await ask(`Add "${s.text}"? (${seen}) [y/n/e/q] `))?.toLowerCase();
58
+ if (answer === undefined || answer.startsWith("q"))
38
59
  break;
39
60
  if (answer.startsWith("y") && s.status === "new")
40
61
  chosen.push({ groupId: s.id, text: s.text });
41
62
  if (answer.startsWith("e")) {
42
63
  const text = await ask("Rule: ");
64
+ if (text === undefined)
65
+ break;
43
66
  if (text)
44
67
  chosen.push({ groupId: s.id, text });
45
68
  }
46
69
  }
47
70
  rl.close();
71
+ if (!answered && !process.stdin.isTTY)
72
+ console.log("--apply asks you one rule at a time; run it in a terminal, or use --json to read the suggestions.");
48
73
  if (chosen.length === 0)
49
74
  return console.log("No rules added."), 0;
50
- const { added, skipped } = await applyRules(target, chosen.map((c) => c.text));
75
+ let result;
76
+ try {
77
+ result = await applyRules(target, chosen.map((c) => c.text));
78
+ }
79
+ catch (e) {
80
+ // The write goes through a temp file and a rename, so nothing is half-written.
81
+ return console.error(`Couldn't write ${target}: ${reason(e)}. No rules were added.`), 1;
82
+ }
83
+ const { added, skipped } = result;
51
84
  try {
52
85
  for (const c of chosen.filter((c) => added.includes(c.text.trim())))
53
86
  await recordAdoption(configDir, { ...c, text: c.text.trim(), target }, new Date().toISOString());
@@ -64,6 +97,7 @@ async function main() {
64
97
  ({ values } = parseArgs({
65
98
  options: {
66
99
  days: { type: "string", default: "30" },
100
+ agent: { type: "string", default: "claude" },
67
101
  project: { type: "string" },
68
102
  json: { type: "boolean" },
69
103
  card: { type: "boolean" },
@@ -81,19 +115,36 @@ async function main() {
81
115
  const days = Number(values.days);
82
116
  if (!(days > 0))
83
117
  return console.error(`--days must be a positive number\n\n${USAGE}`), 1;
118
+ if (values.agent !== "claude" && values.agent !== "codex")
119
+ return console.error(`--agent must be claude or codex\n\n${USAGE}`), 1;
120
+ const agent = values.agent;
84
121
  if (values.apply && (values.json || values.card))
85
122
  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;
123
+ const codex = agent === "codex";
124
+ const configDir = codex ? resolveCodexHome(process.env, homedir()) : resolveConfigDir(process.env, homedir());
125
+ const sessionsDir = join(configDir, codex ? "sessions" : "projects");
126
+ const name = codex ? "Codex" : "Claude Code";
127
+ const pack = codex ? RULE_PACK_CODEX : RULE_PACK;
128
+ const empty = { scannedSessions: 0, skippedLines: 0, skippedFiles: 0, corrections: 0, suggestions: [] };
129
+ if (!existsSync(sessionsDir)) {
130
+ if (values.json)
131
+ return console.log(JSON.stringify(empty, null, 2)), 0;
132
+ const elsewhere = codex ? "If Codex keeps its data elsewhere, set CODEX_HOME to that folder." : "If Claude Code keeps its data elsewhere, set CLAUDE_CONFIG_DIR to that folder.";
133
+ // ponytail: the hint only covers a missing projects folder, not an empty one; widen if Codex-only users still get lost.
134
+ const codexDir = join(resolveCodexHome(process.env, homedir()), "sessions");
135
+ const hint = !codex && existsSync(codexDir) ? `\nFound Codex sessions in ${codexDir}; run nomore --agent codex` : "";
136
+ return console.log(`No ${name} sessions found in ${sessionsDir}\n${elsewhere}\n${pack}${hint}`), 0;
137
+ }
90
138
  const project = values.project && resolve(values.project);
91
- const report = await runPipeline({ configDir, days, project, nowMs: Date.now() });
139
+ const report = await runPipeline({ configDir, days, project, nowMs: Date.now(), agent });
92
140
  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;
141
+ return console.log(values.json ? JSON.stringify(report, null, 2) : noSessions(name, sessionsDir, days, pack, project)), 0;
94
142
  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));
143
+ return applyInteractively(report, codex ? await codexRuleFile(project ?? configDir) : join(project ?? configDir, "CLAUDE.md"), configDir, codex ? NOTHING_YET_CODEX : NOTHING_YET);
144
+ process.stdout.write(values.json ? JSON.stringify(report, null, 2) + "\n" : values.card ? renderCard(report) : renderReport(report, codex ? NOTHING_YET_CODEX : NOTHING_YET));
97
145
  return 0;
98
146
  }
99
- main().then((code) => (process.exitCode = code));
147
+ main().then((code) => (process.exitCode = code), (e) => {
148
+ console.error(`nomore: ${reason(e)}`); // last resort: a message, not a stack trace
149
+ process.exitCode = 1;
150
+ });
package/dist/codex.js ADDED
@@ -0,0 +1,156 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { join, sep } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ export const resolveCodexHome = (env, home) => env.CODEX_HOME ?? join(home, ".codex");
6
+ // The file Codex reads in a directory: a non-blank AGENTS.override.md replaces AGENTS.md. Used for both the rule check and --apply.
7
+ // ponytail: JS trim() also strips a BOM, Rust's may not, so a BOM-only override could differ from Codex; unverified, very unlikely.
8
+ // ponytail: this directory only; the repo-root-to-cwd walk, project_doc_max_bytes and fallback filenames are not modelled (#21).
9
+ export async function codexRuleFile(dir) {
10
+ const override = join(dir, "AGENTS.override.md");
11
+ return (await readFile(override, "utf8").catch(() => "")).trim() ? override : join(dir, "AGENTS.md");
12
+ }
13
+ const isRec = (r) => !!r && typeof r === "object" && typeof r.type === "string" && !!r.payload && typeof r.payload === "object";
14
+ // Lines of a file; the stream is closed when the caller stops early (300k-char prompts are not read for nothing).
15
+ async function* lines(file) {
16
+ const input = createReadStream(file);
17
+ try {
18
+ yield* createInterface({ input, crlfDelay: Infinity });
19
+ }
20
+ finally {
21
+ input.destroy();
22
+ }
23
+ }
24
+ // Subagent threads (guardian review included) and codex exec runs are not typed by the user.
25
+ const isScripted = (p) => p.originator === "codex_exec" || p.source === "exec" || (!!p.source && typeof p.source === "object" && "subagent" in p.source);
26
+ // Accepts both the platform separator and "/", like the pipeline's own project filter.
27
+ const inside = (cwd, project) => {
28
+ const root = project.replace(/[\\/]$/, "");
29
+ return cwd === project || cwd.startsWith(root + sep) || cwd.startsWith(root + "/");
30
+ };
31
+ // True if any cwd the session reports (session_meta, or a later turn_context after a directory change) is inside the project. Throws on an unreadable file.
32
+ async function touchesProject(file, project) {
33
+ for await (const line of lines(file)) {
34
+ if (!line.includes('"cwd"'))
35
+ continue;
36
+ try {
37
+ const r = JSON.parse(line);
38
+ if (isRec(r) && (r.type === "session_meta" || r.type === "turn_context") && typeof r.payload.cwd === "string" && inside(r.payload.cwd, project))
39
+ return true;
40
+ }
41
+ catch { }
42
+ }
43
+ return false;
44
+ }
45
+ async function walk(dir) {
46
+ const out = [];
47
+ for (const e of (await readdir(dir, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name))) {
48
+ if (e.isDirectory())
49
+ out.push(...(await walk(join(dir, e.name))));
50
+ else if (e.name.startsWith("rollout-") && e.name.endsWith(".jsonl"))
51
+ out.push(join(dir, e.name));
52
+ }
53
+ return out;
54
+ }
55
+ export async function listRollouts(codexHome, opts) {
56
+ const files = [];
57
+ for (const path of await walk(join(codexHome, "sessions"))) {
58
+ // Deleted since readdir: skip it. Any other stat error (EACCES) keeps the file, so parseRollout counts it unreadable.
59
+ const mtime = await stat(path).then((st) => st.mtimeMs, (e) => (e.code === "ENOENT" ? -1 : Infinity));
60
+ if (mtime < opts.sinceMs)
61
+ continue;
62
+ // Codex has no folder per project: the cwds are in the file. Lookalikes and turns outside the project are removed later by the cwd check in runPipeline.
63
+ // ponytail: a file with no cwd record (pre-envelope, cut-off) never matches, so --project drops it and it is not counted in skippedLines; add a count if that proves confusing.
64
+ if (opts.project !== undefined) {
65
+ const hit = await touchesProject(path, opts.project).catch(() => null); // null: unreadable, keep it so it is counted
66
+ if (hit === false)
67
+ continue;
68
+ }
69
+ files.push(path);
70
+ }
71
+ return files;
72
+ }
73
+ const parts = (content, type) => Array.isArray(content) ? content.filter((c) => c?.type === type && typeof c.text === "string").map((c) => c.text) : [];
74
+ // The VS Code extension wraps typed text as "<wrapper heading>...\n## My request:\n<typed>"; only the typed part is the user's.
75
+ // ponytail: only the one wrapper seen on real history; another shape (e.g. an IDE-context heading, or a `[N] ...` line before the heading, seen only in subagent files that are skipped whole) leaks until added to WRAPPERS/MARKER.
76
+ const WRAPPERS = ["# Files mentioned by the user:"];
77
+ const REQUEST_MARKER = /^## My request:.*$/m;
78
+ const unwrap = (t) => {
79
+ if (!WRAPPERS.some((w) => t.trimStart().startsWith(w)))
80
+ return t;
81
+ const m = REQUEST_MARKER.exec(t);
82
+ return m ? t.slice(m.index + m[0].length) : "";
83
+ };
84
+ // A file that can't be read (permissions, a folder named rollout-*.jsonl) is reported as unreadable instead of ending the run.
85
+ export async function parseRollout(file) {
86
+ try {
87
+ return { ...(await readRollout(file)), unreadable: false };
88
+ }
89
+ catch {
90
+ return { events: [], skippedLines: 0, unreadable: true };
91
+ }
92
+ }
93
+ async function readRollout(file) {
94
+ const events = [];
95
+ let skippedLines = 0;
96
+ let sessionId; // set by the first session_meta, which decides whether the file counts
97
+ let cwd = "";
98
+ let n = 0;
99
+ for await (const line of lines(file)) {
100
+ n++;
101
+ if (line.trim() === "")
102
+ continue;
103
+ let r;
104
+ try {
105
+ r = JSON.parse(line);
106
+ }
107
+ catch {
108
+ skippedLines++; // a rollout still being written ends in a partial line
109
+ continue;
110
+ }
111
+ // ponytail: rollouts from before the {timestamp,type,payload} envelope are only counted, not read; add a second parser if someone needs them.
112
+ if (!isRec(r)) {
113
+ skippedLines++;
114
+ continue;
115
+ }
116
+ const p = r.payload;
117
+ if (r.type === "session_meta") {
118
+ if (sessionId !== undefined)
119
+ continue; // later ones belong to a parent thread
120
+ if (isScripted(p))
121
+ return { events: [], skippedLines };
122
+ sessionId = String(p.id ?? "");
123
+ if (typeof p.cwd === "string")
124
+ cwd = p.cwd;
125
+ continue;
126
+ }
127
+ if (r.type === "turn_context") {
128
+ if (typeof p.cwd === "string")
129
+ cwd = p.cwd;
130
+ continue;
131
+ }
132
+ // Ranking needs a time; without one the record can't be placed, so it is dropped.
133
+ if (typeof r.timestamp !== "string" || Number.isNaN(Date.parse(r.timestamp)))
134
+ continue;
135
+ const base = { uuid: `${sessionId ?? ""}:${n}`, sessionId: sessionId ?? "", cwd, ts: r.timestamp };
136
+ if (r.type === "event_msg") {
137
+ if (p.type === "turn_aborted" && p.reason === "interrupted")
138
+ events.push({ ...base, kind: "user_marker", text: "interrupted" });
139
+ }
140
+ else if (r.type === "response_item") {
141
+ if (p.type === "message" && p.role === "user") {
142
+ // Injected context arrives as user messages too: AGENTS.md text and tag blocks.
143
+ const text = parts(p.content, "input_text").map(unwrap).filter((t) => !t.startsWith("# AGENTS.md instructions") && !t.trimStart().startsWith("<")).join("\n").trim();
144
+ if (text !== "")
145
+ events.push({ ...base, kind: "user_text", text });
146
+ }
147
+ else if (p.type === "message" && p.role === "assistant") {
148
+ events.push({ ...base, kind: "assistant_text", text: parts(p.content, "output_text").join("\n") });
149
+ }
150
+ else if ((p.type === "function_call" || p.type === "custom_tool_call") && typeof p.name === "string") {
151
+ events.push({ ...base, kind: "assistant_tool_use", tool: p.name, input: {} });
152
+ }
153
+ }
154
+ }
155
+ return { events, skippedLines };
156
+ }
package/dist/detect.js CHANGED
@@ -86,7 +86,44 @@ const FILLER = /^((pls|plz|please|yea|yeah|yep|yes|ok|okay|okey|now|so|just|also
86
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
87
  // A contrast tail ("use pnpm, not npm") qualifies a positive instruction; it doesn't negate it.
88
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, ""));
89
+ // Without the comma too, but only after a choice verb ("use pnpm not npm", "prefer tabs not spaces"): a bare
90
+ // "X not Y" is usually an evaluation ("force pushes not ok", "do it not now") and stays negated. One word after
91
+ // "not", two or more before, no auxiliary ("use what is not needed"), participle tail or evaluative/adverb tail.
92
+ // ponytail: choice verbs and one-word tails only, so "run vitest not jest" and "use pnpm not the npm cli" stay negated.
93
+ // EVAL_TAIL is not exhaustive: a missed word merges opposites ("use it not now"); extend it when one shows up.
94
+ const CHOICE = /^(use|prefer|pick|choose|write|reply|respond|answer|install|go with|stick (with|to))\b/i;
95
+ const EVAL_TAIL = /^(ok|okay|good|fine|bad|great|now|yet|again|anymore|always|ever|never|much|more|less|later|here|there|yes|no|it|this|that|me|you|them|flaky|needed|allowed|acceptable|ideal|welcome|useful|nice|cool|wise|appropriate|necessary|mandatory|helpful|safe|correct|right|wrong|true|today|tomorrow|first|twice|often|everywhere|anywhere|too|so|really|properly)\W*$/i;
96
+ const AUX = /^(do|does|did|is|are|was|were|am|be|been|can|could|should|would|will|must|may|might|shall|have|has|had|need|try|\w+['’](s|re|m|ve|ll|d))$/i;
97
+ const choiceBody = (text) => text.trim().replace(FILLER, "").replace(/^(always|i said)\s+/i, "");
98
+ const isContrastWord = (w) => !/(ed|ing)\W*$/i.test(w) && !EVAL_TAIL.test(w);
99
+ const dropBareContrast = (text) => {
100
+ const body = choiceBody(text);
101
+ const w = body.split(/\s+/);
102
+ const i = w.length - 2; // where "not" would be
103
+ return CHOICE.test(body) && i >= 2 && /^not$/i.test(w[i]) && !AUX.test(w[i - 1]) && isContrastWord(w[i + 1]) ? w.slice(0, i).join(" ") : text;
104
+ };
105
+ // A ", never …" tail is a contrast too (issue #18) before a description ("…, never an instruction") or, after a
106
+ // tool-choice verb, before one word that isn't a common verb ("use pnpm, never npm"). Any other ", never …" stays
107
+ // an instruction ("commit often, never amend", "answer briefly, never ramble", "use the API, never guess").
108
+ // ponytail: NEVER_VERB is not exhaustive; a missed verb merges ("use X, never <verb>"); extend it when one shows up.
109
+ const TOOL_CHOICE = /^(use|prefer|pick|choose|install|go with|stick (with|to))\b/i;
110
+ const NEVER_VERB = /^(guess|assume|ask|skip|stop|wait|ramble|apologi[sz]e|lie|invent|hardcode|mock|retry|rewrite|refactor|rename|delete|remove|overwrite|force|amend|rebase|squash|merge|push|commit|deploy|publish|release|edit|change|touch|modify|comment|explain|summari[sz]e|speculate|hallucinate|loop|repeat|truncate|ignore)\W*$/i;
111
+ const isContrastTail = (head, tail) => {
112
+ if (CONTRAST.test(tail) || /^never\s+(a|an|the)\s/i.test(tail))
113
+ return true;
114
+ const word = tail.match(/^never\s+(\S+)$/i)?.[1];
115
+ return word !== undefined && TOOL_CHOICE.test(choiceBody(head)) && isContrastWord(word) && !NEVER_VERB.test(word);
116
+ };
117
+ // The text without its ", never …" contrast tails, and those tails ("use pnpm, never npm, never yarn").
118
+ const splitNeverTails = (text) => {
119
+ const tails = [];
120
+ for (let m = text.match(/^(.*),\s*(never\b.*)$/i); m && isContrastTail(m[1], m[2]); m = text.match(/^(.*),\s*(never\b.*)$/i)) {
121
+ tails.unshift(m[2]);
122
+ text = m[1];
123
+ }
124
+ return { head: text, tails };
125
+ };
126
+ export const isNegated = (text) => NEGATION.test(dropBareContrast(splitNeverTails(text.replace(/,\s*(not|instead of|rather than)\b.*$/i, "")).head));
90
127
  // Korean (spec › Repeated instructions): Hangul has no \b word boundaries, so these match as substrings.
91
128
  const KO_QUESTION = /뭐|무엇|뭔|어떻게|어떤|어디|언제|왜|얼마|몇|누구|어때|어떨까|(나요|까요|까|니|냐|습니까)[??.!~]*$/;
92
129
  const KO_REQUEST = /(해\s?줘|주세요|줘|해\s?봐|하자|보자|부탁(해|합니다|드려요)?)[.!~]*$/;
@@ -103,8 +140,13 @@ const keep = (clause, question) => {
103
140
  const words = t.filter((x) => !PLACEHOLDER.test(x) && x.length > 1);
104
141
  return !t.includes("<url>") && words.length >= 2 && (t.length - words.length) * 2 <= t.length && !words.every((x) => ACK.has(x));
105
142
  };
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));
143
+ // Grouping tokens: plain plurals are singularised so "words" and "word" match. A contrast "never" counts like the
144
+ // stopword "not", so "use pnpm, never npm" groups with "use pnpm, not npm".
145
+ export const clauseTokens = (text) => {
146
+ const { head, tails } = splitNeverTails(text);
147
+ const asNot = head + tails.map((t) => `, not${t.slice("never".length)}`).join("");
148
+ return tokens(normalize(asNot)).map((t) => (t.length > 3 && t.endsWith("s") && !t.endsWith("ss") ? t.slice(0, -1) : t));
149
+ };
108
150
  export function extractInstructions(events) {
109
151
  const signals = new Map(detectCorrections(events).map((c) => [c.uuid, c.signals]));
110
152
  return events.flatMap((e) => {
@@ -115,7 +157,7 @@ export function extractInstructions(events) {
115
157
  .filter((line) => !isPasted(line.trim()))
116
158
  .join("\n")
117
159
  .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]), [])
160
+ .reduce((parts, p) => (parts.length && isContrastTail(parts.at(-1), p.trim()) ? [...parts.slice(0, -1), `${parts.at(-1)}, ${p.trim()}`] : [...parts, p]), [])
119
161
  .map((raw, i) => ({ c: raw.trim().replace(/[.;!??]+$/, "").trim(), q: /[??]\s*$/.test(raw), i }))
120
162
  .filter(({ c, q }) => keep(c, q))
121
163
  .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) ?? [] }));
package/dist/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join, sep } from "node:path";
2
+ import { codexRuleFile, listRollouts, parseRollout } from "./codex.js";
2
3
  import { clauseTokens, extractInstructions, isNegated } from "./detect.js";
3
4
  import { groupItems } from "./group.js";
4
5
  import { rankGroups } from "./rank.js";
@@ -7,12 +8,20 @@ import { listSessionFiles, parseSession } from "./sessions.js";
7
8
  import { Report } from "./types.js";
8
9
  const GROUP_THRESHOLD = 0.6; // spec › Grouping (clauses)
9
10
  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 codex = opts.agent === "codex";
12
+ const [list, parse] = codex ? [listRollouts, parseRollout] : [listSessionFiles, parseSession];
13
+ const files = await list(opts.configDir, { sinceMs: opts.nowMs - opts.days * 86_400_000, project: opts.project });
11
14
  const corrections = [];
12
15
  let skippedLines = 0;
16
+ let scanned = files.length; // codex: only rollouts that yielded events (subagent/exec ones are skipped); claude keeps every file
17
+ let skippedFiles = 0;
13
18
  for (const f of files) {
14
- const s = await parseSession(f);
19
+ const s = await parse(f);
20
+ if (codex && s.events.length === 0 && !s.unreadable)
21
+ scanned--; // an unreadable file still counts, as for Claude (#26)
15
22
  skippedLines += s.skippedLines;
23
+ if (s.unreadable)
24
+ skippedFiles++;
16
25
  corrections.push(...extractInstructions(s.events));
17
26
  }
18
27
  // Two project paths can share one encoded folder (/tmp/a-b, /tmp/a_b): keep only instructions from inside the project.
@@ -37,10 +46,12 @@ export async function runPipeline(opts) {
37
46
  signals: [...new Set(members.flatMap((m) => m.signals))].sort(),
38
47
  };
39
48
  });
40
- const rules = await readRuleLines([...(opts.project ? [join(opts.project, "CLAUDE.md")] : []), join(opts.configDir, "CLAUDE.md")]);
49
+ const ruleFile = async (dir) => (codex ? codexRuleFile(dir) : join(dir, "CLAUDE.md"));
50
+ const rules = await readRuleLines([...(opts.project ? [await ruleFile(opts.project)] : []), await ruleFile(opts.configDir)], { imports: !codex });
41
51
  return Report.parse({
42
- scannedSessions: files.length,
52
+ scannedSessions: scanned,
43
53
  skippedLines,
54
+ skippedFiles,
44
55
  corrections: corrections.length,
45
56
  suggestions: toSuggestions(rankGroups(groups, opts.nowMs), rules),
46
57
  });
package/dist/report.js CHANGED
@@ -1,14 +1,21 @@
1
1
  // The public repo; change to the org's repo at handover (README's install link too).
2
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`;
3
+ const pack = (file) => `Meanwhile, start with the 20-rule pack: ${REPO_URL}/blob/main/rules/${file}`;
4
+ const nothingYet = (p) => `No instructions repeated across sessions yet.\n${p}`;
5
+ export const RULE_PACK = pack("CLAUDE.md");
6
+ export const NOTHING_YET = nothingYet(RULE_PACK);
7
+ export const RULE_PACK_CODEX = pack("AGENTS.md");
8
+ export const NOTHING_YET_CODEX = nothingYet(RULE_PACK_CODEX);
9
+ export const skippedFilesNote = (r) => `${r.skippedFiles} unreadable ${r.skippedFiles === 1 ? "file" : "files"} skipped`;
4
10
  const line = (s) => `● ${s.text}\n ${s.count}× in ${s.sessions} sessions, last ${s.lastSeen.slice(0, 10)}\n` +
5
11
  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` : "");
12
+ export function renderReport(r, nothing = NOTHING_YET) {
13
+ const head = `nomore · ${r.scannedSessions} sessions · ${r.corrections} instructions` + (r.skippedLines ? ` · ${r.skippedLines} unreadable lines skipped` : "") +
14
+ (r.skippedFiles ? ` · ${skippedFilesNote(r)}` : "");
8
15
  const fresh = r.suggestions.filter((s) => s.status === "new");
9
16
  const ignored = r.suggestions.filter((s) => s.status === "ignored");
10
17
  if (r.suggestions.length === 0)
11
- return `${head}\n\n${NOTHING_YET}\n`;
18
+ return `${head}\n\n${nothing}\n`;
12
19
  return [
13
20
  head,
14
21
  ...(fresh.length ? ["", "Repeated instructions", "", ...fresh.map(line)] : []),
package/dist/rules.js CHANGED
@@ -8,7 +8,8 @@ import { Suggestion } from "./types.js";
8
8
  const RULE_MATCH = 0.3; // spec › Rule check
9
9
  // Bullet or sentence lines of the rules files; headings, blanks and missing files are skipped. A line "@path"
10
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) {
11
+ // AGENTS.md has no imports (Codex), so callers pass imports: false and an "@x" line stays plain text.
12
+ export async function readRuleLines(paths, { imports = true } = {}) {
12
13
  const seen = new Set();
13
14
  const read = async (path) => {
14
15
  if (seen.has(path))
@@ -18,7 +19,7 @@ export async function readRuleLines(paths) {
18
19
  const out = [];
19
20
  for (const raw of text.split("\n")) {
20
21
  const line = raw.trim();
21
- const imp = line.match(/^@(\S+)$/);
22
+ const imp = imports ? line.match(/^@(\S+)$/) : null;
22
23
  if (imp)
23
24
  out.push(...(await read(imp[1].startsWith("~/") ? join(homedir(), imp[1].slice(2)) : resolve(dirname(path), imp[1]))));
24
25
  else if (line !== "" && !line.startsWith("#"))
package/dist/sessions.js CHANGED
@@ -17,7 +17,9 @@ export async function listSessionFiles(configDir, opts) {
17
17
  const names = await readdir(join(root, d)).catch(() => []);
18
18
  for (const f of names.filter((n) => n.endsWith(".jsonl")).sort()) {
19
19
  const path = join(root, d, f);
20
- if ((await stat(path)).mtimeMs >= opts.sinceMs)
20
+ // Deleted since readdir: skip it. Any other stat error (EACCES) keeps the file, so parseSession counts it unreadable.
21
+ const mtime = await stat(path).then((st) => st.mtimeMs, (e) => (e.code === "ENOENT" ? -1 : Infinity));
22
+ if (mtime >= opts.sinceMs)
21
23
  files.push(path);
22
24
  }
23
25
  }
@@ -32,10 +34,13 @@ const resultText = (c) => typeof c === "string" ? c : Array.isArray(c) ? c.map((
32
34
  function toEvents(r) {
33
35
  if (r.isSidechain === true || typeof r.uuid !== "string")
34
36
  return [];
37
+ // Ranking needs a time; without one the record can't be placed, so it is dropped like any other unknown shape.
38
+ if (typeof r.timestamp !== "string" || Number.isNaN(Date.parse(r.timestamp)))
39
+ return [];
35
40
  // sdk-cli, sdk-ts, sdk-py: Claude Code run by a script, whose "user" messages are program-written templates.
36
41
  if (typeof r.entrypoint === "string" && r.entrypoint.startsWith("sdk"))
37
42
  return [];
38
- const base = { uuid: r.uuid, sessionId: String(r.sessionId ?? ""), cwd: String(r.cwd ?? ""), ts: String(r.timestamp ?? "") };
43
+ const base = { uuid: r.uuid, sessionId: String(r.sessionId ?? ""), cwd: String(r.cwd ?? ""), ts: r.timestamp };
39
44
  const content = r.message?.content;
40
45
  if (r.type === "user") {
41
46
  const injected = r.isMeta === true || r.isCompactSummary === true; // compaction summaries are written by Claude Code
@@ -74,22 +79,28 @@ function toEvents(r) {
74
79
  }
75
80
  return [];
76
81
  }
82
+ // A file that can't be read (permissions, a folder named *.jsonl) is reported as unreadable instead of ending the run.
77
83
  export async function parseSession(file) {
78
84
  const events = [];
79
85
  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
+ try {
87
+ for await (const line of createInterface({ input: createReadStream(file), crlfDelay: Infinity })) {
88
+ if (line.trim() === "")
89
+ continue;
90
+ let r;
91
+ try {
92
+ r = JSON.parse(line);
93
+ }
94
+ catch {
95
+ skippedLines++; // a session still being written ends in a partial line
96
+ continue;
97
+ }
98
+ if (r && typeof r === "object")
99
+ events.push(...toEvents(r));
86
100
  }
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
101
  }
94
- return { events, skippedLines };
102
+ catch {
103
+ return { events: [], skippedLines: 0, unreadable: true };
104
+ }
105
+ return { events, skippedLines, unreadable: false };
95
106
  }
package/dist/types.js CHANGED
@@ -48,6 +48,7 @@ export const Suggestion = z.object({
48
48
  export const Report = z.object({
49
49
  scannedSessions: z.number(),
50
50
  skippedLines: z.number(),
51
+ skippedFiles: z.number(),
51
52
  corrections: z.number(),
52
53
  suggestions: z.array(Suggestion),
53
54
  });
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@globalmpc/nomore",
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.",
3
+ "version": "0.2.0",
4
+ "description": "Stop telling Claude Code or Codex the same thing twice: a CLAUDE.md rule pack from ~10k issues, and a local tool that finds what you keep repeating.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "claude",
8
8
  "claude-md",
9
+ "codex",
10
+ "openai-codex",
11
+ "agents-md",
9
12
  "ai-agents",
10
13
  "developer-tools",
11
14
  "cli"