@globalmpc/nomore 0.0.0-stage → 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/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,175 @@
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
+ [![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)
6
+
7
+ **Stop telling Claude Code the same thing twice.**
8
+
9
+ ![nomore: repeated instructions become CLAUDE.md rules](assets/demo.gif)
10
+
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
27
+
28
+ ```bash
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
31
+ ```
32
+
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 |
57
+
58
+ Top 5:
59
+
60
+ - Don't add code comments unless they explain something non-obvious; match the comment density of the surrounding code, and stop when asked.
61
+ 1 issue · 252 reactions · [#65961](https://github.com/anthropics/claude-code/issues/65961)
62
+ - Don't use the word "load-bearing" or other recurring jargon; say what you mean in plain words.
63
+ 1 issue · 186 reactions · [#53454](https://github.com/anthropics/claude-code/issues/53454)
64
+ - Never run destructive git commands (`reset --hard`, `checkout -- .`, `clean -f`, `push --force`) or schedule them without explicit permission.
65
+ 1 issue · 121 reactions · [#40710](https://github.com/anthropics/claude-code/issues/40710)
66
+ - Keep typographic characters (curly quotes, em dashes, ellipses) exactly as written; don't replace them with ASCII.
67
+ 1 issue · 61 reactions · [#1599](https://github.com/anthropics/claude-code/issues/1599)
68
+ - Don't use the idiom "belt-and-suspenders"; vary phrasing and keep it literal.
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.
81
+
82
+ How they were chosen: every `anthropics/claude-code` issue labelled `area:model` or `data-loss`, fetched
83
+ 2026-10-31 (8,196 after dropping pull requests, duplicates and withdrawn reports). The 8,096 with words in their
84
+ title were grouped by title similarity ≥ 0.5 and ranked by Σ(1 + reactions); 20 behaviours a `CLAUDE.md` rule can
85
+ change were then picked by hand, from issues still open or closed as not planned. Each rule currently rests on one
86
+ issue and its reactions. Reproduce with `bash scripts/fetch-issues.sh && pnpm mine`.
87
+
88
+ **If a rule saved you a correction, star the repo so others find it.** Know a rule that belongs here?
89
+ [Propose it](../../issues/new?template=propose-a-rule.md).
90
+
91
+ ## How the CLI works
92
+
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.
95
+
96
+ ```bash
97
+ npx @globalmpc/nomore # what you keep telling Claude (sessions active in the last 30 days)
98
+ npx @globalmpc/nomore --apply # pick suggestions one by one (yes / no / edit) and add them to CLAUDE.md
99
+ npx @globalmpc/nomore --project . # only this project; --apply then writes this project's CLAUDE.md
100
+ npx @globalmpc/nomore --card # a short summary to share: totals only
101
+ ```
102
+
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
+ ```
123
+
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.
161
+
162
+ ## Contributing
163
+
164
+ Rules, wrong suggestions and code are all welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ pnpm install
170
+ pnpm test
171
+ ```
172
+
173
+ ## License
174
+
175
+ 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,150 @@
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 { codexRuleFile, resolveCodexHome } from "./codex.js";
9
+ import { runPipeline } from "./pipeline.js";
10
+ import { NOTHING_YET, NOTHING_YET_CODEX, RULE_PACK, RULE_PACK_CODEX, renderCard, renderReport, skippedFilesNote } from "./report.js";
11
+ import { resolveConfigDir } from "./sessions.js";
12
+ const USAGE = `Usage: nomore [--days <n>] [--agent claude|codex] [--project <path>] [--json | --card | --apply]
13
+
14
+ Finds what you keep telling Claude Code (or Codex) and suggests CLAUDE.md (or AGENTS.md) rules. Runs locally; writes only with --apply.
15
+ --days <n> sessions active in the last n days (default 30)
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)
18
+ --json machine-readable report
19
+ --card short summary to share: totals only, no suggestion text
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}`;
30
+ // Spec › Machine-supported decision: nothing is written unless the user picks a rule.
31
+ async function applyInteractively(report, target, configDir, nothingYet) {
32
+ if (report.skippedFiles)
33
+ console.log(skippedFilesNote(report));
34
+ if (report.suggestions.length === 0)
35
+ return console.log(nothingYet), 0;
36
+ const rl = createInterface({ input: process.stdin });
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;
40
+ const ask = async (q) => {
41
+ process.stdout.write(q);
42
+ const r = await lines.next();
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;
50
+ };
51
+ console.log(`Rules will be added to ${target}\n`);
52
+ const chosen = [];
53
+ for (const s of report.suggestions) {
54
+ const seen = `${s.count}× in ${s.sessions} sessions`;
55
+ const answer = (s.status === "ignored"
56
+ ? await ask(`Your rule "${s.matchedRule}" isn't followed (${seen}). Add a reworded rule? [e/n/q] `)
57
+ : await ask(`Add "${s.text}"? (${seen}) [y/n/e/q] `))?.toLowerCase();
58
+ if (answer === undefined || answer.startsWith("q"))
59
+ break;
60
+ if (answer.startsWith("y") && s.status === "new")
61
+ chosen.push({ groupId: s.id, text: s.text });
62
+ if (answer.startsWith("e")) {
63
+ const text = await ask("Rule: ");
64
+ if (text === undefined)
65
+ break;
66
+ if (text)
67
+ chosen.push({ groupId: s.id, text });
68
+ }
69
+ }
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.");
73
+ if (chosen.length === 0)
74
+ return console.log("No rules added."), 0;
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;
84
+ try {
85
+ for (const c of chosen.filter((c) => added.includes(c.text.trim())))
86
+ await recordAdoption(configDir, { ...c, text: c.text.trim(), target }, new Date().toISOString());
87
+ }
88
+ catch (e) {
89
+ console.error(`Rules were added, but the adoption log wasn't updated: ${e.message}`);
90
+ }
91
+ console.log(`✓ ${added.length} ${added.length === 1 ? "rule" : "rules"} added to ${target}` + (skipped.length ? ` (${skipped.length} already there)` : ""));
92
+ return 0;
93
+ }
94
+ async function main() {
95
+ let values;
96
+ try {
97
+ ({ values } = parseArgs({
98
+ options: {
99
+ days: { type: "string", default: "30" },
100
+ agent: { type: "string", default: "claude" },
101
+ project: { type: "string" },
102
+ json: { type: "boolean" },
103
+ card: { type: "boolean" },
104
+ apply: { type: "boolean" },
105
+ help: { type: "boolean" },
106
+ },
107
+ }));
108
+ }
109
+ catch (e) {
110
+ console.error(`${e.message}\n\n${USAGE}`);
111
+ return 1;
112
+ }
113
+ if (values.help)
114
+ return console.log(USAGE), 0;
115
+ const days = Number(values.days);
116
+ if (!(days > 0))
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;
121
+ if (values.apply && (values.json || values.card))
122
+ return console.error(`--apply can't be combined with --json or --card\n\n${USAGE}`), 1;
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
+ }
138
+ const project = values.project && resolve(values.project);
139
+ const report = await runPipeline({ configDir, days, project, nowMs: Date.now(), agent });
140
+ if (report.scannedSessions === 0)
141
+ return console.log(values.json ? JSON.stringify(report, null, 2) : noSessions(name, sessionsDir, days, pack, project)), 0;
142
+ if (values.apply)
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));
145
+ return 0;
146
+ }
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
+ }