fapony 0.3.3 → 0.3.4

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.
Files changed (81) hide show
  1. package/fapony.ts +3 -116
  2. package/package.json +2 -2
  3. package/skill/move-to-done/SKILL.md +3 -2
  4. package/src/adapters/cli.ts +123 -0
  5. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  6. package/src/adapters/hooks/context-data.ts +102 -0
  7. package/src/adapters/hooks/edit-hint.ts +195 -0
  8. package/src/adapters/hooks/git-autonomy.ts +178 -0
  9. package/src/adapters/hooks/index.ts +79 -0
  10. package/src/adapters/hooks/mv-guard.ts +52 -0
  11. package/src/adapters/hooks/read-hint.ts +383 -0
  12. package/src/adapters/hooks/session-start.ts +101 -0
  13. package/src/adapters/hooks/stop.ts +302 -0
  14. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  15. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  17. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  19. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  20. package/src/adapters/mcp/types.ts +18 -0
  21. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  22. package/src/analyze.ts +1 -1
  23. package/src/conventions-seed.ts +1 -1
  24. package/src/core/config.ts +199 -0
  25. package/src/core/debt-format.ts +107 -0
  26. package/src/core/debt-types.ts +79 -0
  27. package/src/core/defaults.ts +8 -0
  28. package/src/core/enums.ts +34 -0
  29. package/src/core/format.ts +33 -0
  30. package/src/core/hint-log.ts +68 -0
  31. package/src/core/hook-helpers.ts +31 -0
  32. package/src/core/mem-log.ts +357 -0
  33. package/src/core/parse.ts +71 -0
  34. package/src/core/pricing.ts +217 -0
  35. package/src/core/safety.ts +18 -0
  36. package/src/core/types.ts +142 -0
  37. package/src/core/util.ts +105 -0
  38. package/src/db/store.ts +2 -2
  39. package/src/debt/cli.ts +1 -1
  40. package/src/debt/format.ts +2 -107
  41. package/src/debt/load.ts +1 -1
  42. package/src/debt/promotion.ts +1 -1
  43. package/src/debt/types.ts +14 -79
  44. package/src/digest/collect.ts +4 -3
  45. package/src/gate.ts +2 -2
  46. package/src/gates.ts +1 -1
  47. package/src/hook.ts +69 -1424
  48. package/src/init-mem.ts +1 -1
  49. package/src/init.ts +1 -1
  50. package/src/install/claude.ts +17 -0
  51. package/src/install/opencode.ts +114 -0
  52. package/src/install.ts +12 -5
  53. package/src/lint-baseline.ts +1 -2
  54. package/src/map.ts +29 -8
  55. package/src/mem/commands/plan.ts +70 -42
  56. package/src/mem/store.ts +5 -1
  57. package/src/memory.ts +24 -387
  58. package/src/parse.ts +9 -71
  59. package/src/price/fetch.ts +4 -16
  60. package/src/price/resolve.ts +12 -213
  61. package/src/report/cli.ts +3 -3
  62. package/src/safety.ts +2 -18
  63. package/src/seed/plan-seed.ts +8 -4
  64. package/src/seed/primitives.ts +1 -7
  65. package/src/session/types.ts +14 -128
  66. package/src/setup.ts +1 -1
  67. package/src/stats/data.ts +3 -3
  68. package/src/telemetry.ts +2 -2
  69. package/src/usage/cache.ts +1 -2
  70. package/src/usage/cli.ts +1 -1
  71. package/src/usage/scan.ts +2 -1
  72. package/src/util.ts +10 -93
  73. package/src/web/html.ts +2 -33
  74. package/src/db/defaults.ts +0 -34
  75. package/src/db/getters.ts +0 -35
  76. package/src/db/index.ts +0 -7
  77. package/src/db/load.ts +0 -57
  78. package/src/db/types.ts +0 -77
  79. package/src/mcp/types.ts +0 -54
  80. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  81. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
package/fapony.ts CHANGED
@@ -1,121 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  // fapony — measure/verify MCP server for coding agents
4
- // CLI dispatch: all logic lives in src/
4
+ // CLI dispatch lives in src/adapters/cli.ts
5
5
 
6
- import { existsSync } from "node:fs";
7
- import { cmdAnalyze } from "./src/analyze.js";
8
- import { cmdDebt } from "./src/debt/cli.js";
9
- import { cmdDigest } from "./src/digest/cli.js";
10
- import {
11
- cmdHookEditHint,
12
- cmdHookReadHint,
13
- cmdHookSessionStart,
14
- cmdHookStop,
15
- } from "./src/hook.js";
16
- import { cmdInit } from "./src/init.js";
17
- import { cmdInitMem } from "./src/init-mem.js";
18
- import { cmdInstall } from "./src/install.js";
19
- import { cmdLintBaseline } from "./src/lint-baseline.js";
20
- import { cmdMcp } from "./src/mcp/transport.js";
21
- import { cmdMem } from "./src/mem/index.js";
22
- import { initStore } from "./src/mem/store.js";
23
- import { cmdPriceScan } from "./src/price/index.js";
24
- import { cmdReport, cmdReportWeb } from "./src/report/index.js";
25
- import { cmdPlanSeed } from "./src/seed/plan-seed.js";
26
- import { cmdReviewSeed } from "./src/seed/review-seed.js";
27
- import { cmdSetup } from "./src/setup.js";
28
- import { cmdStats } from "./src/stats/index.js";
29
- import { cmdTelemetry } from "./src/telemetry.js";
30
- import { cmdUpdate } from "./src/update.js";
31
- import { cmdUsageScan, cmdUsageWeb } from "./src/usage/index.js";
6
+ import { cliMain } from "./src/adapters/cli.js";
32
7
 
33
- const [cmd, ...a] = process.argv.slice(2);
34
-
35
- if (cmd === "analyze") {
36
- cmdAnalyze(a);
37
- } else if (cmd === "debt") {
38
- cmdDebt(a);
39
- } else if (cmd === "lint-baseline") {
40
- cmdLintBaseline(a);
41
- } else if (cmd === "plan-seed") {
42
- cmdPlanSeed(a);
43
- } else if (cmd === "review-seed") {
44
- cmdReviewSeed(a);
45
- } else if (cmd === "digest") {
46
- await cmdDigest(a);
47
- } else if (cmd === "stats") {
48
- cmdStats(a);
49
- } else if (cmd === "telemetry") {
50
- await cmdTelemetry(a);
51
- } else if (cmd === "init-mem") {
52
- cmdInitMem(a);
53
- } else if (cmd === "mem") {
54
- // `--mem-dir <path>` is global to `mem` and must reach both the writer
55
- // (initStore) and the resolver behind `mem where` — parse it once and thread
56
- // it through, never strip it and forget.
57
- const memDirIdx = a.indexOf("--mem-dir");
58
- let overrideMemDir: string | undefined;
59
- let rest = a;
60
- if (memDirIdx !== -1) {
61
- overrideMemDir = a[memDirIdx + 1];
62
- if (!overrideMemDir || overrideMemDir.startsWith("--")) {
63
- console.error("fapony mem: --mem-dir needs a value");
64
- process.exit(1);
65
- }
66
- if (!existsSync(overrideMemDir)) {
67
- console.error(
68
- `fapony mem: --mem-dir path does not exist: ${overrideMemDir}`,
69
- );
70
- process.exit(1);
71
- }
72
- rest = a.filter((_, i) => i !== memDirIdx && i !== memDirIdx + 1);
73
- }
74
- initStore(process.cwd(), overrideMemDir);
75
- try {
76
- await cmdMem(rest, overrideMemDir);
77
- } catch (e) {
78
- console.error(`fapony mem: ${e instanceof Error ? e.message : String(e)}`);
79
- process.exit(1);
80
- }
81
- } else if (cmd === "init") {
82
- await cmdInit(a);
83
- } else if (cmd === "install") {
84
- await cmdInstall(a);
85
- } else if (cmd === "setup") {
86
- await cmdSetup();
87
- } else if (cmd === "update") {
88
- await cmdUpdate();
89
- } else if (cmd === "hook-stop") {
90
- await cmdHookStop();
91
- } else if (cmd === "hook-read-hint") {
92
- await cmdHookReadHint();
93
- } else if (cmd === "hook-edit-hint") {
94
- await cmdHookEditHint();
95
- } else if (cmd === "hook-session-start") {
96
- await cmdHookSessionStart();
97
- } else if (cmd === "mcp") {
98
- cmdMcp();
99
- } else if (cmd === "report") {
100
- cmdReport(a);
101
- } else if (cmd === "report-web") {
102
- cmdReportWeb(a);
103
- } else if (cmd === "usage-scan") {
104
- cmdUsageScan(a);
105
- } else if (cmd === "price-scan") {
106
- await cmdPriceScan(a);
107
- } else if (cmd === "usage-web") {
108
- cmdUsageWeb(a);
109
- } else if (cmd === "test") {
110
- // dynamic: src/test.js re-exports test/index.js, which the npm package
111
- // doesn't ship (repo self-check only, not a published command) — a static
112
- // import here would fail module load for every command, not just this one
113
- const { cmdTest } = await import("./src/test.js");
114
- await cmdTest();
115
- } else {
116
- console.error(`fapony: unknown command "${cmd ?? ""}"`);
117
- console.error(
118
- "usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|hook-session-start|test> [args]",
119
- );
120
- process.exit(1);
121
- }
8
+ cliMain();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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)",
@@ -18,7 +18,7 @@
18
18
  "coding-agent"
19
19
  ],
20
20
  "bin": {
21
- "fapony": "./fapony.ts"
21
+ "fapony": "fapony.ts"
22
22
  },
23
23
  "files": [
24
24
  "fapony.ts",
@@ -40,8 +40,9 @@ You are about to move a PLAN that has been shipped to the archive.
40
40
  of `done/`, which is what `plan-sweep` and `kickoff` go by.
41
41
 
42
42
  2. **Run `plan-sweep --apply`** — this does the `git mv`, rewrites markdown links inside the
43
- file and inbound links from other plan files, warns about plain-text mentions, and logs
44
- a decision row — all in one call:
43
+ file and inbound links from every `.md` under `.fapony/` (`plan/`, `done/`, `spec/`),
44
+ warns about plain-text mentions and about tracked files outside `.fapony/` that still
45
+ name the file (both detect-only), and logs a decision row — all in one call:
45
46
  ```bash
46
47
  fapony mem plan-sweep <PLAN-foo.md> --apply
47
48
  ```
@@ -0,0 +1,123 @@
1
+ // src/adapters/cli.ts — CLI dispatch: parse argv and route to feature modules
2
+ //
3
+ // Extracted from fapony.ts (PLAN-lib-layer chunk 3). The entry point
4
+ // (fapony.ts) calls cliMain(); this file owns all feature imports and the
5
+ // argv routing.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { cmdAnalyze } from "../analyze.js";
9
+ import { cmdDebt } from "../debt/cli.js";
10
+ import { cmdDigest } from "../digest/cli.js";
11
+ import { cmdInit } from "../init.js";
12
+ import { cmdInitMem } from "../init-mem.js";
13
+ import { cmdInstall } from "../install.js";
14
+ import { cmdLintBaseline } from "../lint-baseline.js";
15
+ import { cmdMem } from "../mem/index.js";
16
+ import { initStore } from "../mem/store.js";
17
+ import { cmdPriceScan } from "../price/index.js";
18
+ import { cmdReport, cmdReportWeb } from "../report/index.js";
19
+ import { cmdPlanSeed } from "../seed/plan-seed.js";
20
+ import { cmdReviewSeed } from "../seed/review-seed.js";
21
+ import { cmdSetup } from "../setup.js";
22
+ import { cmdStats } from "../stats/index.js";
23
+ import { cmdTelemetry } from "../telemetry.js";
24
+ import { cmdUpdate } from "../update.js";
25
+ import { cmdUsageScan, cmdUsageWeb } from "../usage/index.js";
26
+ import {
27
+ cmdHookEditHint,
28
+ cmdHookMvGuard,
29
+ cmdHookReadHint,
30
+ cmdHookSessionStart,
31
+ cmdHookStop,
32
+ } from "./hooks/index.js";
33
+ import { cmdMcp } from "./mcp/transport.js";
34
+
35
+ export async function cliMain(): Promise<void> {
36
+ const [cmd, ...a] = process.argv.slice(2);
37
+
38
+ if (cmd === "analyze") {
39
+ cmdAnalyze(a);
40
+ } else if (cmd === "debt") {
41
+ cmdDebt(a);
42
+ } else if (cmd === "lint-baseline") {
43
+ cmdLintBaseline(a);
44
+ } else if (cmd === "plan-seed") {
45
+ cmdPlanSeed(a);
46
+ } else if (cmd === "review-seed") {
47
+ cmdReviewSeed(a);
48
+ } else if (cmd === "digest") {
49
+ await cmdDigest(a);
50
+ } else if (cmd === "stats") {
51
+ cmdStats(a);
52
+ } else if (cmd === "telemetry") {
53
+ await cmdTelemetry(a);
54
+ } else if (cmd === "init-mem") {
55
+ cmdInitMem(a);
56
+ } else if (cmd === "mem") {
57
+ const memDirIdx = a.indexOf("--mem-dir");
58
+ let overrideMemDir: string | undefined;
59
+ let rest = a;
60
+ if (memDirIdx !== -1) {
61
+ overrideMemDir = a[memDirIdx + 1];
62
+ if (!overrideMemDir || overrideMemDir.startsWith("--")) {
63
+ console.error("fapony mem: --mem-dir needs a value");
64
+ process.exit(1);
65
+ }
66
+ if (!existsSync(overrideMemDir)) {
67
+ console.error(
68
+ `fapony mem: --mem-dir path does not exist: ${overrideMemDir}`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+ rest = a.filter((_, i) => i !== memDirIdx && i !== memDirIdx + 1);
73
+ }
74
+ initStore(process.cwd(), overrideMemDir);
75
+ try {
76
+ await cmdMem(rest, overrideMemDir);
77
+ } catch (e) {
78
+ console.error(
79
+ `fapony mem: ${e instanceof Error ? e.message : String(e)}`,
80
+ );
81
+ process.exit(1);
82
+ }
83
+ } else if (cmd === "init") {
84
+ await cmdInit(a);
85
+ } else if (cmd === "install") {
86
+ await cmdInstall(a);
87
+ } else if (cmd === "setup") {
88
+ await cmdSetup();
89
+ } else if (cmd === "update") {
90
+ await cmdUpdate();
91
+ } else if (cmd === "hook-stop") {
92
+ await cmdHookStop();
93
+ } else if (cmd === "hook-read-hint") {
94
+ await cmdHookReadHint();
95
+ } else if (cmd === "hook-edit-hint") {
96
+ await cmdHookEditHint();
97
+ } else if (cmd === "hook-mv-guard") {
98
+ await cmdHookMvGuard();
99
+ } else if (cmd === "hook-session-start") {
100
+ await cmdHookSessionStart();
101
+ } else if (cmd === "mcp") {
102
+ cmdMcp();
103
+ } else if (cmd === "report") {
104
+ cmdReport(a);
105
+ } else if (cmd === "report-web") {
106
+ cmdReportWeb(a);
107
+ } else if (cmd === "usage-scan") {
108
+ cmdUsageScan(a);
109
+ } else if (cmd === "price-scan") {
110
+ await cmdPriceScan(a);
111
+ } else if (cmd === "usage-web") {
112
+ cmdUsageWeb(a);
113
+ } else if (cmd === "test") {
114
+ const { cmdTest } = await import("../test.js");
115
+ await cmdTest();
116
+ } else {
117
+ console.error(`fapony: unknown command "${cmd ?? ""}"`);
118
+ console.error(
119
+ "usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|hook-mv-guard|hook-session-start|test> [args]",
120
+ );
121
+ process.exit(1);
122
+ }
123
+ }
@@ -0,0 +1,107 @@
1
+ // src/adapters/hooks/compute-hint-impact.ts — hint-fire impact computation
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Reads hint log + re-runs
4
+ // debt detection to count resolved vs unresolved hints.
5
+
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import {
9
+ type HintFireRow,
10
+ type HintImpact,
11
+ hintLogDir,
12
+ worktreeKey,
13
+ } from "../../core/hint-log.js";
14
+ import { debtForFile, loadConventions } from "../../debt/index.js";
15
+
16
+ /**
17
+ * Compute hint-fire impact from the log. `since` is an ISO date string;
18
+ * omit to scan all rows. `worktree` scopes to one project's log file.
19
+ */
20
+ export function computeHintImpact(
21
+ since?: string,
22
+ worktree?: string,
23
+ ): HintImpact {
24
+ const dir = hintLogDir();
25
+ const impact: HintImpact = {
26
+ fired: 0,
27
+ by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
28
+ debt: { shown: 0, resolved: 0, unknown: 0 },
29
+ window: since ?? null,
30
+ };
31
+
32
+ if (!existsSync(dir)) return impact;
33
+
34
+ let files: string[];
35
+ try {
36
+ const all = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
37
+ files = worktree
38
+ ? all.filter((f) => f === `${worktreeKey(worktree)}.jsonl`)
39
+ : all;
40
+ } catch {
41
+ return impact;
42
+ }
43
+
44
+ const debtShown = new Map<string, true>();
45
+ const debtByFile = new Map<string, string[]>();
46
+
47
+ for (const file of files) {
48
+ let content: string;
49
+ try {
50
+ content = readFileSync(join(dir, file), "utf-8");
51
+ } catch {
52
+ continue;
53
+ }
54
+ for (const line of content.split("\n")) {
55
+ if (!line) continue;
56
+ let row: HintFireRow;
57
+ try {
58
+ row = JSON.parse(line) as HintFireRow;
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (since && row.ts < since) continue;
63
+ impact.fired++;
64
+ impact.by_surface[row.surface]++;
65
+
66
+ if (row.surface === "debt" && row.ids && row.file) {
67
+ const key = `${row.worktree}\t${row.file}`;
68
+ const existing = debtByFile.get(key) ?? [];
69
+ for (const id of row.ids) {
70
+ const dk = `${row.worktree}\t${row.file}\t${id}`;
71
+ if (!debtShown.has(dk)) {
72
+ debtShown.set(dk, true);
73
+ existing.push(id);
74
+ }
75
+ }
76
+ debtByFile.set(key, existing);
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const [key, ids] of debtByFile) {
82
+ const [worktree, file] = key.split("\t");
83
+ const absFile = join(worktree, file);
84
+ let currentIds: Set<string>;
85
+ try {
86
+ if (!statSync(absFile).isFile()) {
87
+ impact.debt.unknown += ids.length;
88
+ continue;
89
+ }
90
+ const convs = debtForFile(worktree, absFile, loadConventions(worktree));
91
+ currentIds = new Set(convs.map((c) => c.id));
92
+ } catch {
93
+ impact.debt.unknown += ids.length;
94
+ continue;
95
+ }
96
+ for (const id of ids) {
97
+ impact.debt.shown++;
98
+ if (currentIds.has(id)) {
99
+ // still present — not resolved
100
+ } else {
101
+ impact.debt.resolved++;
102
+ }
103
+ }
104
+ }
105
+
106
+ return impact;
107
+ }
@@ -0,0 +1,102 @@
1
+ // src/adapters/hooks/context-data.ts — shared debt + mem context reader
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Used by both read-hint and
4
+ // edit-hint adapters to attach debt/mem lines when a file is open.
5
+
6
+ import { realpathSync } from "node:fs";
7
+ import { basename, join, relative } from "node:path";
8
+ import { collectSourceFiles, SCAN_EXTS } from "../../analyze.js";
9
+ import { debtForFile, loadConventions } from "../../debt/index.js";
10
+ import { readMemLog } from "../../memory.js";
11
+
12
+ const DEBT_HINT_MAX = 3;
13
+ const MEM_HINT_MAX = 2;
14
+ const MEM_TEXT_MAX = 120;
15
+
16
+ export interface ContextLineData {
17
+ worktree: string;
18
+ debtIds: string[];
19
+ debtLines: string[];
20
+ memLines: string[];
21
+ }
22
+
23
+ /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
24
+ export function readContextData(
25
+ filePath: unknown,
26
+ cwd: string,
27
+ ): ContextLineData | null {
28
+ try {
29
+ if (typeof filePath !== "string" || filePath === "") return null;
30
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
31
+ cwd,
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ if (git.exitCode !== 0) return null;
36
+ const worktree = realpathSync(git.stdout.toString().trim());
37
+ const abs = realpathSync(
38
+ filePath.startsWith("/") ? filePath : join(worktree, filePath),
39
+ );
40
+ const rel = relative(worktree, abs).split("\\").join("/");
41
+ if (rel.startsWith("..") || rel === "") return null;
42
+
43
+ const debtIds: string[] = [];
44
+ const debtLines: string[] = [];
45
+ const memLines: string[] = [];
46
+
47
+ // convention debt — source files only, fresh from the repo
48
+ const dot = rel.lastIndexOf(".");
49
+ if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
50
+ for (const c of debtForFile(
51
+ worktree,
52
+ abs,
53
+ loadConventions(worktree),
54
+ ).slice(0, DEBT_HINT_MAX)) {
55
+ debtIds.push(c.id);
56
+ debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
57
+ }
58
+ }
59
+
60
+ // mem rows that are about this file
61
+ const mem = readMemLog(worktree);
62
+ if (mem.rows.length > 0) {
63
+ const base = basename(rel);
64
+ const direct: typeof mem.rows = [];
65
+ const baseOnly: typeof mem.rows = [];
66
+ for (const r of mem.rows) {
67
+ if (r.kind === "claim" || r.kind === "release") continue;
68
+ const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
69
+ if ((r.files ?? []).includes(rel) || hay.includes(rel)) {
70
+ direct.push(r);
71
+ continue;
72
+ }
73
+ if (base && hay.includes(base)) baseOnly.push(r);
74
+ }
75
+ let usableBase = baseOnly;
76
+ if (baseOnly.length > 0) {
77
+ const sameName = collectSourceFiles(worktree).filter(
78
+ (f) => basename(f) === base,
79
+ ).length;
80
+ if (sameName !== 1) usableBase = [];
81
+ }
82
+ const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
83
+ for (const r of memHits) {
84
+ memLines.push(
85
+ `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
86
+ );
87
+ }
88
+ }
89
+ return { worktree, debtIds, debtLines, memLines };
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function readContextLines(filePath: unknown, cwd: string): string[] {
96
+ const data = readContextData(filePath, cwd);
97
+ if (!data) return [];
98
+ return [...data.debtLines, ...data.memLines].slice(
99
+ 0,
100
+ DEBT_HINT_MAX + MEM_HINT_MAX,
101
+ );
102
+ }
@@ -0,0 +1,195 @@
1
+ // src/adapters/hooks/edit-hint.ts — Edit hint: importer count + once-per-session dedupe
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Attaches importer count
4
+ // when editing a source file.
5
+
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join, relative, sep } from "node:path";
15
+ import { buildGraphCached, SCAN_EXTS } from "../../analyze.js";
16
+ import { recordHintFire } from "../../core/hint-log.js";
17
+ import { sessionKey } from "../../core/hook-helpers.js";
18
+ import { readContextData } from "./context-data.js";
19
+
20
+ const EDIT_TRACK_DIR = "edit-track";
21
+
22
+ export interface EditTrackRow {
23
+ ts: string;
24
+ path: string;
25
+ }
26
+
27
+ /** Directory holding one edit log per session. */
28
+ function editTrackDir(): string {
29
+ const base =
30
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
31
+ return join(base, EDIT_TRACK_DIR);
32
+ }
33
+
34
+ /** Absolute path of a session's edit log — may not exist. */
35
+ export function editTrackPath(session: string): string {
36
+ return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
37
+ }
38
+
39
+ function editTrackPaths(session: string): Set<string> {
40
+ const p = editTrackPath(session);
41
+ if (!existsSync(p)) return new Set();
42
+ const out = new Set<string>();
43
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
44
+ if (!line) continue;
45
+ try {
46
+ const r = JSON.parse(line) as EditTrackRow;
47
+ if (typeof r.path === "string") out.add(r.path);
48
+ } catch {
49
+ // a torn line must not lose the rest of the log
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function appendEditTrackRow(session: string, row: EditTrackRow): void {
56
+ const dir = editTrackDir();
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+ appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
59
+ }
60
+
61
+ export interface EditHintInput {
62
+ filePath: unknown;
63
+ cwd: string;
64
+ session?: unknown;
65
+ }
66
+
67
+ /**
68
+ * Factual one-liner for editing a source file that has importers, or null.
69
+ * Every unknown resolves to null — a hint must never fire on a guess.
70
+ */
71
+ export function editHintFor(opts: EditHintInput): string | null {
72
+ try {
73
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
74
+ const dot = opts.filePath.lastIndexOf(".");
75
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
76
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
77
+ cwd: opts.cwd,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ if (git.exitCode !== 0) return null;
82
+ const worktree = realpathSync(git.stdout.toString().trim());
83
+ const abs = (() => {
84
+ const p = opts.filePath.startsWith("/")
85
+ ? opts.filePath
86
+ : join(opts.cwd, opts.filePath);
87
+ try {
88
+ return realpathSync(p);
89
+ } catch {
90
+ return null;
91
+ }
92
+ })();
93
+ if (!abs) return null;
94
+ const rel = relative(worktree, abs).split(sep).join("/");
95
+ if (rel.startsWith("..") || rel === "") return null;
96
+
97
+ const importers = buildGraphCached(worktree).dependents.get(rel);
98
+ if (!importers || importers.size === 0) return null;
99
+
100
+ if (typeof opts.session === "string" && opts.session !== "") {
101
+ if (editTrackPaths(opts.session).has(abs)) return null;
102
+ appendEditTrackRow(opts.session, {
103
+ ts: new Date().toISOString(),
104
+ path: abs,
105
+ });
106
+ }
107
+
108
+ const n = importers.size;
109
+ return (
110
+ `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
111
+ `review-seed --files ${rel} lists them (add --callers <export> for one ` +
112
+ `export's callers); check before changing its shape (skill /lookup-before-edit)`
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out. */
120
+ export async function cmdHookEditHint(): Promise<void> {
121
+ try {
122
+ const raw = JSON.parse(await Bun.stdin.text()) as {
123
+ cwd?: string;
124
+ transcript_path?: string;
125
+ session_id?: string;
126
+ tool_input?: {
127
+ file_path?: unknown;
128
+ };
129
+ };
130
+ const cwd = raw.cwd ?? process.cwd();
131
+ const filePath = raw.tool_input?.file_path;
132
+ const session = raw.transcript_path ?? raw.session_id;
133
+ const parts: string[] = [];
134
+ const hint = editHintFor({ filePath, cwd, session });
135
+ if (hint) parts.push(hint);
136
+ const ctx = readContextData(filePath, cwd);
137
+ if (ctx) {
138
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
139
+ parts.push(line);
140
+ }
141
+ }
142
+ if (parts.length > 0) {
143
+ console.log(
144
+ JSON.stringify({
145
+ hookSpecificOutput: {
146
+ hookEventName: "PreToolUse",
147
+ additionalContext: parts.join("\n"),
148
+ },
149
+ }),
150
+ );
151
+ }
152
+
153
+ // --- hint-fire log ---
154
+ if (hint) {
155
+ try {
156
+ const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
157
+ cwd,
158
+ stdout: "pipe",
159
+ stderr: "pipe",
160
+ });
161
+ if (g.exitCode === 0) {
162
+ const worktree = realpathSync(g.stdout.toString().trim());
163
+ const abs =
164
+ typeof filePath === "string"
165
+ ? (() => {
166
+ try {
167
+ return realpathSync(
168
+ filePath.startsWith("/")
169
+ ? filePath
170
+ : join(worktree, filePath),
171
+ );
172
+ } catch {
173
+ return null;
174
+ }
175
+ })()
176
+ : null;
177
+ const rel = abs
178
+ ? relative(worktree, abs).split("\\").join("/")
179
+ : null;
180
+ recordHintFire({
181
+ ts: new Date().toISOString(),
182
+ worktree,
183
+ surface: "edit",
184
+ file: rel && !rel.startsWith("..") ? rel : null,
185
+ count: 1,
186
+ });
187
+ }
188
+ } catch {
189
+ // best-effort — swallow
190
+ }
191
+ }
192
+ } catch {
193
+ // any failure = no hint; a hook must never block an edit over a hint
194
+ }
195
+ }