@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 +120 -20
- package/dist/cli.js +71 -20
- package/dist/codex.js +156 -0
- package/dist/detect.js +46 -4
- package/dist/pipeline.js +15 -4
- package/dist/report.js +11 -4
- package/dist/rules.js +3 -2
- package/dist/sessions.js +26 -15
- package/dist/types.js +1 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,37 +1,86 @@
|
|
|
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)
|
|
4
6
|
|
|
5
|
-
|
|
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
|

|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 |
|
|
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 ·
|
|
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 ·
|
|
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-
|
|
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
|
|
91
|
+
## How the CLI works
|
|
43
92
|
|
|
44
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Filters are tuned for English, Korean and Indonesian. If nothing repeats yet, it points you to
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
--
|
|
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(
|
|
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
|
-
|
|
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] `))
|
|
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
|
-
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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) :
|
|
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
|
-
|
|
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
|
-
|
|
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) => (
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
102
|
+
catch {
|
|
103
|
+
return { events: [], skippedLines: 0, unreadable: true };
|
|
104
|
+
}
|
|
105
|
+
return { events, skippedLines, unreadable: false };
|
|
95
106
|
}
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@globalmpc/nomore",
|
|
3
|
-
"version": "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"
|