fapony 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # fapony
6
6
 
7
- [![npm](https://img.shields.io/npm/v/fapony.svg)](https://www.npmjs.com/package/fapony)
7
+ [![npm](https://img.shields.io/npm/v/fapony.svg)](https://www.npmjs.com/package/fapony) [![GitHub](https://img.shields.io/github/stars/kire21b/fapony.svg)](https://github.com/kire21b/fapony)
8
8
 
9
9
  **See what your coding agents actually cost.** fapony reads the session logs Claude Code, Codex,
10
10
  OpenCode and ZCode already write, and puts them all on one yardstick — tokens, cost and time per
@@ -99,7 +99,11 @@ fapony install --all # skip the prompt, wire everything detected
99
99
  # 4. Turn on the memory layer (per project you want it in)
100
100
  fapony init /path/to/your-worktree
101
101
  # creates .fapony/ — .memory/ (the mem log the 3 MCP tools read and write)
102
- # and conventions.json for `fapony debt` (shared rules: commit them)
102
+ # and conventions.json for `fapony debt` (shared rules: commit them),
103
+ # then offers to write the memory rules into CLAUDE.md / AGENTS.md
104
+ # (none yet = AGENTS.md + a CLAUDE.md that imports it) — agents only log
105
+ # what the rules they already read tell them to
106
+ fapony init /path/to/your-worktree --rules --yes # repo already set up: rules only, no prompt
103
107
  ```
104
108
 
105
109
  …or add it manually to any MCP client: `{ "mcpServers": { "fapony": { "command": "fapony", "args": ["mcp"] } } }`. Full protocol and adapter examples: [docs/mcp-handcheck.md](https://github.com/kire21b/fapony/blob/main/docs/mcp-handcheck.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
@@ -19,6 +19,8 @@ export interface ContextLineData {
19
19
  memLines: string[];
20
20
  /** Ids of open bugs actually emitted as OPEN BUG lines above (for the fire log). */
21
21
  openBugIds: string[];
22
+ /** Ids of every mem row emitted in memLines (open bugs first) — joined to authors offline. */
23
+ memIds: string[];
22
24
  }
23
25
 
24
26
  /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
@@ -45,6 +47,7 @@ export function readContextData(
45
47
  const debtLines: string[] = [];
46
48
  const memLines: string[] = [];
47
49
  const openBugIds: string[] = [];
50
+ const memIds: string[] = [];
48
51
 
49
52
  // convention debt — source files only, fresh from the repo. The scope
50
53
  // pairs the git root (repo-relative `where`) with the nearest
@@ -112,13 +115,15 @@ export function readContextData(
112
115
  .filter((r) => !(r.id && emitted.has(r.id)))
113
116
  .slice(0, MEM_HINT_MAX - openLines.length);
114
117
  for (const line of openLines) memLines.push(line);
118
+ memIds.push(...openBugIds);
115
119
  for (const r of rest) {
120
+ if (r.id) memIds.push(r.id);
116
121
  memLines.push(
117
122
  `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
118
123
  );
119
124
  }
120
125
  }
121
- return { worktree, debtIds, debtLines, memLines, openBugIds };
126
+ return { worktree, debtIds, debtLines, memLines, openBugIds, memIds };
122
127
  } catch {
123
128
  return null;
124
129
  }
@@ -184,6 +184,16 @@ export async function cmdHookEditHint(): Promise<void> {
184
184
  file: rel && !rel.startsWith("..") ? rel : null,
185
185
  count: 1,
186
186
  });
187
+ if (ctx && ctx.memLines.length > 0) {
188
+ recordHintFire({
189
+ ts: new Date().toISOString(),
190
+ worktree,
191
+ surface: "mem",
192
+ file: rel && !rel.startsWith("..") ? rel : null,
193
+ count: ctx.memLines.length,
194
+ ids: ctx.memIds,
195
+ });
196
+ }
187
197
  if (ctx && ctx.openBugIds.length > 0) {
188
198
  recordHintFire({
189
199
  ts: new Date().toISOString(),
@@ -390,6 +390,7 @@ export async function cmdHookReadHint(): Promise<void> {
390
390
  surface: "mem",
391
391
  file: rel,
392
392
  count: ctx.memLines.length,
393
+ ids: ctx.memIds,
393
394
  });
394
395
  }
395
396
  if (ctx && ctx.openBugIds.length > 0) {
@@ -156,9 +156,17 @@ function runCommand(
156
156
  timeoutMs: number,
157
157
  ): CommandOutcome {
158
158
  const start = Date.now();
159
+ // A bare `pytest` otherwise hits a global one whose editable install may
160
+ // point at another worktree — tests the wrong code, silently.
161
+ // ponytail: .venv only; poetry/uv/conda when someone asks.
162
+ const venvBin = join(worktree, ".venv", "bin");
163
+ const env = existsSync(venvBin)
164
+ ? { ...process.env, PATH: `${venvBin}:${process.env.PATH ?? ""}` }
165
+ : undefined;
159
166
  try {
160
167
  execSync(cmd, {
161
168
  cwd: worktree,
169
+ env,
162
170
  encoding: "utf-8",
163
171
  stdio: ["pipe", "pipe", "pipe"],
164
172
  timeout: timeoutMs,
@@ -66,7 +66,7 @@ export function diagnose(
66
66
  for (const f of graph.files) {
67
67
  const deps = graph.dependents.get(f) ?? new Set<string>();
68
68
  if (deps.size === 0) {
69
- if (!isEntryPoint(f) && !isTestFile(f)) {
69
+ if (!isEntryPoint(f) && !graph.entries?.has(f) && !isTestFile(f)) {
70
70
  findings.push({
71
71
  kind: "orphan",
72
72
  file: f,
@@ -5,7 +5,7 @@
5
5
  // Same module also serves handoff_check / verification_report: blastRadius()
6
6
  // (see blast.ts) turns facts.files[] into per-file { dependents, tested } facts.
7
7
 
8
- import { readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync } from "node:fs";
9
9
  import { join, resolve } from "node:path";
10
10
 
11
11
  import { isBarrelSource } from "./criteria.js";
@@ -20,6 +20,49 @@ import {
20
20
  import { IMPORT_TYPE_RE, REQUIRE_RE, resolveRelative } from "./resolve-ts.js";
21
21
  import type { ImportGraph } from "./types.js";
22
22
 
23
+ // Root pyproject.toml only: declared deps (normalized to import-name guesses)
24
+ // and `[project.scripts]` targets. A dist whose import name differs
25
+ // (scikit-learn → sklearn) stays unresolved — fail-safe toward "don't know".
26
+ function readPyproject(absDir: string): {
27
+ deps: Set<string>;
28
+ scripts: string[];
29
+ } {
30
+ const deps = new Set<string>();
31
+ const scripts: string[] = [];
32
+ const path = join(absDir, "pyproject.toml");
33
+ if (!existsSync(path)) return { deps, scripts };
34
+ let project: {
35
+ dependencies?: unknown;
36
+ "optional-dependencies"?: Record<string, unknown>;
37
+ scripts?: Record<string, unknown>;
38
+ };
39
+ try {
40
+ project =
41
+ (
42
+ Bun.TOML.parse(readFileSync(path, "utf-8")) as {
43
+ project?: typeof project;
44
+ }
45
+ ).project ?? {};
46
+ } catch {
47
+ return { deps, scripts };
48
+ }
49
+ const specs = [
50
+ project.dependencies,
51
+ ...Object.values(project["optional-dependencies"] ?? {}),
52
+ ].flatMap((v) => (Array.isArray(v) ? v : []));
53
+ for (const spec of specs) {
54
+ const name =
55
+ typeof spec === "string" && spec.match(/^\s*([A-Za-z0-9._-]+)/)?.[1];
56
+ if (name) deps.add(name.toLowerCase().replace(/[-.]/g, "_"));
57
+ }
58
+ for (const target of Object.values(project.scripts ?? {})) {
59
+ if (typeof target === "string") scripts.push(target.split(":")[0].trim());
60
+ }
61
+ return { deps, scripts };
62
+ }
63
+
64
+ const PY_MAIN_RE = /^if\s+__name__\s*==\s*["']__main__["']\s*:/m;
65
+
23
66
  export function buildGraph(dir: string): ImportGraph {
24
67
  const absDir = resolve(dir);
25
68
  const files = collectSourceFiles(absDir);
@@ -32,6 +75,8 @@ export function buildGraph(dir: string): ImportGraph {
32
75
  let external = 0;
33
76
  // Built lazily — a TS-only repo never pays for it.
34
77
  let pyIndex: Map<string, string> | null = null;
78
+ let pyproject: ReturnType<typeof readPyproject> | null = null;
79
+ const entries = new Set<string>();
35
80
 
36
81
  const transpiler = new Bun.Transpiler({ loader: "ts" });
37
82
 
@@ -50,11 +95,18 @@ export function buildGraph(dir: string): ImportGraph {
50
95
  // against the module index. An absolute miss whose root is in PY_STDLIB
51
96
  // is external; a third-party package or a real miss stays unresolved.
52
97
  pyIndex ??= buildPyModuleIndex(filesSet);
98
+ pyproject ??= readPyproject(absDir);
99
+ if (PY_MAIN_RE.test(content)) entries.add(rel);
53
100
  const pyEdges = new Set<string>();
54
101
  for (const imp of scanPythonImports(content)) {
55
102
  const hits = resolvePythonImport(rel, imp, filesSet, pyIndex);
56
103
  if (hits.size > 0) for (const h of hits) pyEdges.add(h);
57
- else if (!imp.dots && imp.mod && PY_STDLIB.has(pyRootSegment(imp.mod)))
104
+ else if (
105
+ !imp.dots &&
106
+ imp.mod &&
107
+ (PY_STDLIB.has(pyRootSegment(imp.mod)) ||
108
+ pyproject.deps.has(pyRootSegment(imp.mod).toLowerCase()))
109
+ )
58
110
  external++;
59
111
  else unresolved++;
60
112
  }
@@ -107,5 +159,12 @@ export function buildGraph(dir: string): ImportGraph {
107
159
  }
108
160
  }
109
161
 
110
- return { files, deps, dependents, unresolved, external, barrels };
162
+ if (pyIndex && pyproject) {
163
+ for (const mod of pyproject.scripts) {
164
+ const hit = pyIndex.get(mod);
165
+ if (hit) entries.add(hit);
166
+ }
167
+ }
168
+
169
+ return { files, deps, dependents, unresolved, external, barrels, entries };
111
170
  }
@@ -19,6 +19,9 @@ export interface ImportGraph {
19
19
  external: number;
20
20
  /** Files that only re-export (`export ... from`) — they hide the real importer. */
21
21
  barrels: Set<string>;
22
+ /** Python files that run on their own: `__main__` block or `[project.scripts]`.
23
+ * Optional — the graph cache doesn't carry it; only diagnose reads it. */
24
+ entries?: Set<string>;
22
25
  }
23
26
 
24
27
  export type FindingKind =
package/src/init.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  existsSync,
9
9
  mkdirSync,
10
10
  readFileSync,
11
+ realpathSync,
11
12
  writeFileSync,
12
13
  } from "node:fs";
13
14
  import { dirname, join, relative } from "node:path";
@@ -58,22 +59,23 @@ const EVIDENCE_JSON = `{
58
59
  }
59
60
  `;
60
61
 
61
- // Rules snippet for the user's own agent-rules file. Printed, never written:
62
- // nothing writes log.<person>.jsonl on its own — an agent does, because the rules
63
- // file it already reads says to. That file is the user's (CLAUDE.md / AGENTS.md /
64
- // opencode.json instructions), so fapony hands over the text and stays out of it.
65
- // Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
66
- // and most of them never ran `fapony init` — it would tell them to run a command
67
- // that does not exist.
68
- const RULES_SNIPPET =
69
- () => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
62
+ // Rules snippet for the user's own agent-rules file. Nothing writes
63
+ // log.<person>.jsonl on its own — an agent does, because the rules file it already
64
+ // reads says to. That file is the user's, so init asks before writing it (rule 6c)
65
+ // and never writes it twice (RULES_MARKER). Not in SERVER_INSTRUCTIONS either: that
66
+ // reaches every MCP session of every user, and most of them never ran `fapony init`
67
+ // — it would tell them to run a command that does not exist.
68
+ const RULES_MARKER = "## Memory: .fapony/.memory";
69
+ const RULES_SNIPPET = () => `${RULES_MARKER}/log.<you>.jsonl (append-only)
70
70
 
71
71
  The log is this project's shared brain — it lives in git, so anyone who clones the
72
72
  repo gets every decision, bug and note with it. The filename comes from
73
73
  \`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
74
74
  .gitattributes keeps both sides when two people end up sharing a name anyway.
75
75
 
76
- Log as you work — do not wait to be asked. Nothing writes it for you:
76
+ Log as you work — do not wait to be asked. Nothing writes it for you. With the
77
+ fapony MCP server connected, call mem_add / mem_find / mem_close directly;
78
+ otherwise the CLI:
77
79
 
78
80
  fapony mem kickoff <plan.md> # start a session with this
79
81
  fapony mem add decision "what was locked, and why" --files src/x.ts
@@ -82,6 +84,14 @@ Log as you work — do not wait to be asked. Nothing writes it for you:
82
84
  fapony mem close <id> "fixed in <sha>"
83
85
  fapony mem find "<text>"
84
86
 
87
+ What goes in — would someone cloning this repo tomorrow need it, and can they not
88
+ find it anywhere else?
89
+ - bug — something broken, even when found mid-task on something else: add it now,
90
+ not at the end. Once fixed, close it — only close closes a bug.
91
+ - decision — something agreed or locked that git and the plan do not say, with why.
92
+ - note — state the next session needs (where a chunk stopped, what is half-done).
93
+ Before ending a turn that committed work: at least one row about it.
94
+
85
95
  Write each entry standalone — it is read months later with no chat to refer to.
86
96
  --files is required: rows that name no file cannot be recalled when that file is
87
97
  touched later (add refuses without it).
@@ -188,64 +198,124 @@ export function initProject(targetPath: string, config?: Config): void {
188
198
  );
189
199
  console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
190
200
  console.log(
191
- `\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
201
+ `\nThe memory log only fills up if the rules your agent already reads tell it\nto write — these go into CLAUDE.md / AGENTS.md next:\n`,
192
202
  );
193
203
  console.log(RULES_SNIPPET());
194
204
  }
195
205
 
196
206
  const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
197
207
 
198
- export async function cmdInit(args: string[]): Promise<void> {
199
- const targetPath = args[0];
200
- if (!targetPath) {
201
- console.error("usage: fapony init <path>");
202
- process.exit(1);
208
+ /** Which rules files init would touch: existing ones lacking the rules, or create. */
209
+ export function rulesTargets(targetPath: string): {
210
+ create: boolean;
211
+ append: string[];
212
+ } {
213
+ // one real file per entry: AGENTS.md -> CLAUDE.md symlinks are common, and
214
+ // appending through both names wrote the rules twice (wt-falsify 2026-09-24)
215
+ const seen = new Set<string>();
216
+ const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter((f) => {
217
+ if (!existsSync(f)) return false;
218
+ const real = realpathSync(f);
219
+ if (seen.has(real)) return false;
220
+ seen.add(real);
221
+ return true;
222
+ });
223
+ const has = (f: string) => readFileSync(f, "utf-8").includes(RULES_MARKER);
224
+ const agents = join(targetPath, "AGENTS.md");
225
+ // a CLAUDE.md that imports a rules-carrying AGENTS.md already has them
226
+ const covered = (f: string) =>
227
+ has(f) ||
228
+ (/^@AGENTS\.md\s*$/m.test(readFileSync(f, "utf-8")) &&
229
+ existsSync(agents) &&
230
+ has(agents));
231
+ return {
232
+ create: found.length === 0,
233
+ append: found.filter((f) => !covered(f)),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Write the memory rules into the agent-rules files. No file yet = AGENTS.md
239
+ * (read by OpenCode/Codex/Cursor) + a CLAUDE.md that imports it, so the rules
240
+ * exist once. Files that already carry the rules are left alone.
241
+ */
242
+ export function writeRules(targetPath: string): void {
243
+ const { create, append } = rulesTargets(targetPath);
244
+ if (create) {
245
+ writeFileSync(join(targetPath, "AGENTS.md"), `${RULES_SNIPPET()}\n`);
246
+ writeFileSync(join(targetPath, "CLAUDE.md"), "@AGENTS.md\n");
247
+ console.log(" + AGENTS.md (memory rules) · CLAUDE.md → @AGENTS.md");
248
+ return;
203
249
  }
204
- try {
205
- initProject(targetPath);
206
- } catch (e) {
207
- console.error((e as Error).message);
208
- process.exit(1);
250
+ for (const f of append) {
251
+ const body = readFileSync(f, "utf-8");
252
+ appendFileSync(
253
+ f,
254
+ `${body.endsWith("\n") ? "\n" : "\n\n"}${RULES_SNIPPET()}\n`,
255
+ );
256
+ console.log(` appended memory rules to ${relative(targetPath, f)}`);
209
257
  }
258
+ }
210
259
 
211
- // --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
212
- // eslint no-restricted-* rows carry their checker; the wrapper detector adds
213
- // live-migration candidates. Nothing derivable = empty file, never an error.
214
- const seed = await seedConventionsFile(targetPath);
215
- if (seed.kept) {
216
- console.log(
217
- ` ${relative(targetPath, seed.file)} — already exists, left untouched`,
218
- );
219
- } else {
220
- console.log(
221
- ` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
222
- );
223
- console.log(
224
- ` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
260
+ export async function cmdInit(args: string[]): Promise<void> {
261
+ const rulesOnly = args.includes("--rules");
262
+ const yes = args.includes("--yes");
263
+ const targetPath = args.find((a) => !a.startsWith("--"));
264
+ if (!targetPath) {
265
+ console.error("usage: fapony init <path> [--rules] [--yes]");
266
+ console.error(
267
+ " --rules only write the memory rules into CLAUDE.md / AGENTS.md (repo already set up)",
225
268
  );
269
+ console.error(" --yes write them without asking");
270
+ process.exit(1);
226
271
  }
227
- for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
272
+ if (!rulesOnly) {
273
+ try {
274
+ initProject(targetPath);
275
+ } catch (e) {
276
+ console.error((e as Error).message);
277
+ process.exit(1);
278
+ }
228
279
 
229
- const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter(
230
- existsSync,
231
- );
232
- if (found.length === 0) return;
280
+ // --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
281
+ // eslint no-restricted-* rows carry their checker; the wrapper detector adds
282
+ // live-migration candidates. Nothing derivable = empty file, never an error.
283
+ const seed = await seedConventionsFile(targetPath);
284
+ if (seed.kept) {
285
+ console.log(
286
+ ` ${relative(targetPath, seed.file)} — already exists, left untouched`,
287
+ );
288
+ } else {
289
+ console.log(
290
+ ` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
291
+ );
292
+ console.log(
293
+ ` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
294
+ );
295
+ }
296
+ for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
297
+ }
233
298
 
234
- const rl = createInterface({ input: process.stdin, output: process.stdout });
235
- const answer = await new Promise<string>((resolve) => {
236
- rl.question(
237
- `\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N] `,
238
- (a) => {
299
+ const { create, append } = rulesTargets(targetPath);
300
+ if (!create && append.length === 0) {
301
+ console.log(" memory rules already in the agent-rules file");
302
+ return;
303
+ }
304
+ if (!yes) {
305
+ const what = create
306
+ ? "Create AGENTS.md with the memory-logging rules (+ CLAUDE.md → @AGENTS.md)"
307
+ : `Append the memory-logging rules to ${append.map((f) => relative(targetPath, f)).join(" and ")}`;
308
+ const rl = createInterface({
309
+ input: process.stdin,
310
+ output: process.stdout,
311
+ });
312
+ const answer = await new Promise<string>((resolve) => {
313
+ rl.question(`\n${what}? [y/N] `, (a) => {
239
314
  rl.close();
240
315
  resolve(a.trim());
241
- },
242
- );
243
- });
244
- if (!isAffirmative(answer)) return;
245
-
246
- const snippet = `\n\n${RULES_SNIPPET()}\n`;
247
- for (const f of found) {
248
- appendFileSync(f, snippet);
249
- console.log(` appended to ${relative(targetPath, f)}`);
316
+ });
317
+ });
318
+ if (!isAffirmative(answer)) return;
250
319
  }
320
+ writeRules(targetPath);
251
321
  }
@@ -269,7 +269,7 @@ export const FaponyReadHint = async ({ directory }) => {
269
269
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
270
270
  }
271
271
  if (ctx.memLines.length > 0) {
272
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
272
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
273
273
  }
274
274
  if (ctx.openBugIds.length > 0) {
275
275
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
@@ -413,7 +413,7 @@ export const FaponyEditHint = async ({ directory }) => {
413
413
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
414
414
  }
415
415
  if (ctx?.memLines?.length) {
416
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
416
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
417
417
  }
418
418
  if (ctx?.openBugIds?.length) {
419
419
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });