@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 +21 -0
- package/README.md +174 -2
- package/dist/apply.js +74 -0
- package/dist/cli.js +150 -0
- package/dist/codex.js +156 -0
- package/dist/detect.js +165 -0
- package/dist/group.js +109 -0
- package/dist/issues.js +59 -0
- package/dist/mine.js +37 -0
- package/dist/normalize.js +18 -0
- package/dist/pipeline.js +58 -0
- package/dist/rank.js +18 -0
- package/dist/report.js +29 -0
- package/dist/rules.js +69 -0
- package/dist/sessions.js +106 -0
- package/dist/types.js +54 -0
- package/package.json +49 -4
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
|
-
#
|
|
1
|
+
# nomore
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@globalmpc/nomore)
|
|
4
|
+
[](https://github.com/globalmpc/nomore/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**Stop telling Claude Code the same thing twice.**
|
|
8
|
+
|
|
9
|
+

|
|
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
|
+

|
|
43
|
+
|
|
44
|
+
## The rule pack: 20 rules from over 8,000 issues
|
|
45
|
+
|
|
46
|
+

|
|
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
|
+
}
|