fapony 0.3.0 → 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 (96) hide show
  1. package/README.md +87 -65
  2. package/fapony.ts +3 -116
  3. package/package.json +6 -5
  4. package/skill/move-to-done/SKILL.md +22 -32
  5. package/skill/review-pony/SKILL.md +27 -58
  6. package/src/adapters/cli.ts +123 -0
  7. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  8. package/src/adapters/hooks/context-data.ts +102 -0
  9. package/src/adapters/hooks/edit-hint.ts +195 -0
  10. package/src/adapters/hooks/git-autonomy.ts +178 -0
  11. package/src/adapters/hooks/index.ts +79 -0
  12. package/src/adapters/hooks/mv-guard.ts +52 -0
  13. package/src/adapters/hooks/read-hint.ts +383 -0
  14. package/src/adapters/hooks/session-start.ts +101 -0
  15. package/src/adapters/hooks/stop.ts +302 -0
  16. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
  18. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  20. package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
  21. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  22. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  23. package/src/{mcp → adapters/mcp}/transport.ts +4 -39
  24. package/src/adapters/mcp/types.ts +18 -0
  25. package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
  26. package/src/analyze.ts +2 -2
  27. package/src/conventions-seed.ts +1 -1
  28. package/src/core/config.ts +199 -0
  29. package/src/core/debt-format.ts +107 -0
  30. package/src/core/debt-types.ts +79 -0
  31. package/src/core/defaults.ts +8 -0
  32. package/src/core/enums.ts +34 -0
  33. package/src/core/format.ts +33 -0
  34. package/src/core/hint-log.ts +68 -0
  35. package/src/core/hook-helpers.ts +31 -0
  36. package/src/core/mem-log.ts +357 -0
  37. package/src/core/parse.ts +71 -0
  38. package/src/core/pricing.ts +217 -0
  39. package/src/core/safety.ts +18 -0
  40. package/src/core/types.ts +142 -0
  41. package/src/core/util.ts +105 -0
  42. package/src/db/store.ts +2 -2
  43. package/src/debt/cli.ts +1 -1
  44. package/src/debt/format.ts +2 -107
  45. package/src/debt/load.ts +1 -1
  46. package/src/debt/promotion.ts +1 -1
  47. package/src/debt/types.ts +14 -79
  48. package/src/digest/collect.ts +4 -3
  49. package/src/gate.ts +5 -5
  50. package/src/gates.ts +1 -1
  51. package/src/hook.ts +69 -1337
  52. package/src/init-mem.ts +58 -72
  53. package/src/init.ts +13 -17
  54. package/src/install/antigravity.ts +112 -0
  55. package/src/install/claude.ts +19 -123
  56. package/src/install/detect.ts +17 -7
  57. package/src/install/opencode.ts +167 -26
  58. package/src/install.ts +23 -7
  59. package/src/lint-baseline.ts +1 -2
  60. package/src/map.ts +29 -8
  61. package/src/mem/commands/plan.ts +70 -42
  62. package/src/mem/commands/read.ts +219 -146
  63. package/src/mem/index.ts +4 -13
  64. package/src/mem/store.ts +5 -1
  65. package/src/memory.ts +24 -387
  66. package/src/parse.ts +9 -71
  67. package/src/price/fetch.ts +4 -16
  68. package/src/price/resolve.ts +12 -213
  69. package/src/report/cli.ts +3 -3
  70. package/src/safety.ts +2 -18
  71. package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
  72. package/src/seed/primitives.ts +60 -0
  73. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  74. package/src/session/types.ts +14 -128
  75. package/src/setup.ts +1 -1
  76. package/src/stats/data.ts +4 -4
  77. package/src/telemetry.ts +3 -3
  78. package/src/usage/cache.ts +1 -2
  79. package/src/usage/cli.ts +1 -1
  80. package/src/usage/scan.ts +2 -1
  81. package/src/util.ts +10 -32
  82. package/src/web/html.ts +2 -33
  83. package/templates/SPEC.md +8 -1
  84. package/images/logo.png +0 -0
  85. package/images/logo.webp +0 -0
  86. package/images/logo@400.webp +0 -0
  87. package/images/sample.webp +0 -0
  88. package/images/summary.webp +0 -0
  89. package/src/db/defaults.ts +0 -34
  90. package/src/db/getters.ts +0 -35
  91. package/src/db/index.ts +0 -7
  92. package/src/db/load.ts +0 -57
  93. package/src/db/types.ts +0 -77
  94. package/src/math.ts +0 -13
  95. package/src/mcp/tools/verdict.ts +0 -161
  96. package/src/mcp/types.ts +0 -54
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: review-pony
3
- description: Review a plan, PR, diff, or design doc as a verification rather than an opinion — scope first, walk the real path, break it on paper, cite everything. Takes optional effort (low|medium|high|max, widens the walk only, never skips a pass) and --fix (apply CONFIRMED blocker/major findings after the report). Records the verdict to fapony after the report. Trigger on /review-pony and proactively whenever the user asks to review, audit, scrutinize, sanity-check, or get a second opinion on a plan, PR, diff, design doc, or proposed code change.
3
+ description: Review a plan, PR, diff, or design doc as a verification rather than an opinion — scope first, walk the real path, break it on paper, cite everything. Takes optional effort (low|medium|high|max, widens the walk only, never skips a pass) and --fix (apply CONFIRMED blocker/major findings after the report). Records surviving findings to the project's mem log after the report. Trigger on /review-pony and proactively whenever the user asks to review, audit, scrutinize, sanity-check, or get a second opinion on a plan, PR, diff, design doc, or proposed code change.
4
4
  ---
5
5
 
6
6
  # Review Pony
@@ -83,10 +83,10 @@ Write down every place the walk surprises you. Surprises outrank style; chase th
83
83
  - `low` — direct callers only, one hop. No test reading unless the diff touches a test file.
84
84
  - `medium` (default) — as written above: full path, callers, tests on the path.
85
85
  - `high` — also second-degree callers, and read the tests that exercise them, not just the path.
86
- - `max` — also run `get_impact_radius_tool` (or grep if the graph isn't wired) on every changed
87
- file and re-open every `deferred` line from the last review of this scope, if fapony has one.
86
+ - `max` — also grep every changed file for second-degree callers, and re-open
87
+ every `deferred` line from the last review of this scope, if fapony has one.
88
88
 
89
- Whatever level stopped you, say so in the one-line coverage note (rule below) — "walked to 1 hop"
89
+ Whatever level stopped you, say so in the one-line coverage note (see Report) — "walked to 1 hop"
90
90
  is honest, "walked" alone at `low` is not.
91
91
 
92
92
  ## Pass 3 — A finding needs a failing input
@@ -161,56 +161,25 @@ fixed: 1, 2 · skipped: 3 (PLAUSIBLE — could not reach the failing state)
161
161
  ```
162
162
 
163
163
  Fixing changes what actually shipped, not what the review found — re-run pass 4's citation
164
- check on the new state before calling it done, but don't re-run the whole review. Submit the
165
- verdict on what you found, not on the post-fix state (`verdict_submit`'s `note` can say the fix
166
- was applied).
167
-
168
- ## After: record the verdict (fapony)
169
-
170
- Call `verdict_submit` once, after the report is shown. Don't block the report on it, and don't
171
- let it change the report's content. Pass `regime="review"` — required, and a review is what this
172
- was; it is what puts this run in the `regime × model` table.
173
-
174
- | Report verdict | `verdict` |
175
- |---|---|
176
- | ship, 0 findings | `pass-excellent` |
177
- | ship, nit-only findings | `pass-good` |
178
- | fix-then-ship | `pass-adequate` |
179
- | rework / reject | `fail` |
180
- | could not walk enough to have a verdict | `uncertain` |
181
-
182
- `uncertain` is not a softer `fail`. It is the honest answer when the walk never
183
- reached the thing under review — the branch wouldn't build, the path is behind a
184
- service you cannot run, every finding came out `PLAUSIBLE`. Say so in the report
185
- too. Guessing `pass` there is the one outcome that makes the ledger lie.
186
-
187
- `reason_code` — the *lead* (most severe) finding, not a generic bucket:
188
-
189
- - **0 findings, or a clean pass → `none`** — never `other`. `other` means "a real
190
- finding that none of these buckets name", so filing clean passes there puts them
191
- in the recurring-fail-reasons list, where they crowd out the reasons that mean
192
- something. It is the one value in this table that costs other people accuracy.
193
- - missing or weak test coverage on the path you walked → `missing_test`
194
- - change is narrower or wider than the plan / PR description claims → `scope_mismatch`
195
- - a shell/eval/deploy command runs without the guard it needs → `unsafe_command`
196
- - the plan or spec didn't cover a case the walk exposed → `spec_gap`
197
- - the change stops short of what it set out to do → `incomplete`
198
- - a real finding none of the above names → `other`, and then `note` is **required**
199
-
200
- Always attach a one-line `note` — the only field a later review can act on. Say what broke or
201
- was walked, not that a review happened.
202
-
203
- Args: `verdict`, `reason_code`, `note`, `regime`, `worktree` — **absolute path** via
204
- `git rev-parse --show-toplevel`, never a bare name (`runs.worktree` is free text; a bare name
205
- writes where no query reads it and every fapony tool misses the run), `plan` (the PLAN file
206
- path under review, omitted for a bare PR/diff), and `files` — the repo-relative paths you
207
- actually walked. **Always send `files`.** It is the only input to per-file risk history; a
208
- verdict without it tells the next session that something failed but not where. No `run_id` —
209
- fapony reuses the latest still-open run for the same worktree+plan (so round 2+ counts toward
210
- the round cap), creating a row only when none is open. `session_id` (optional) — the client
211
- session id, only if the client exposes it; attribute the model, never block the submit on it.
212
- If `verdict_submit` errors, say so in one line and move on — never re-run a review because
213
- storage failed.
164
+ check on the new state before calling it done, but don't re-run the whole review. Record
165
+ what you found, not the post-fix state (the row's text can say the fix was applied).
166
+
167
+ ## After: record what the next session needs (fapony)
168
+
169
+ If a blocker/major CONFIRMED finding survived, or the verdict is rework/reject,
170
+ call `mem_add` once, after the report is shown. Don't block the report on it,
171
+ and don't let it change the report's content. Clean reviews (ship, nit-only
172
+ findings) record nothing — there is nothing the next session needs to find.
173
+
174
+ kind is `bug` when a finding survived (something is broken), `decision` when
175
+ the verdict turns on scope alone (rework/reject from pass 1 — the review locks
176
+ a direction). text is the report's verdict line, standalone — what broke or was
177
+ decided, not that a review happened. files are the repo-relative paths actually
178
+ walked — required, a row without them is unfindable. worktree is the absolute
179
+ path (`git rev-parse --show-toplevel`), never a bare name. Only record into a
180
+ project that already has a mem log — never create one uninvited.
181
+ If `mem_add` errors, say so in one line and move on — never re-run a review
182
+ because storage failed.
214
183
 
215
184
  ---
216
185
 
@@ -232,10 +201,10 @@ The four passes are the rules. These three are what they fail on in practice:
232
201
  ```
233
202
  1-4. scope holds; walked the new gate branch; ran the evidence command — it exits 0
234
203
  without running the suite (CONFIRMED: `bun test` with no test dir exits 0)
235
- post. verdict_submit(verdict="pass-adequate", reason_code="other", regime="review",
236
- note="evidence entry `bun test` exits 0 while running zero tests — real entry is `bun run test`",
237
- worktree="/Users/you/Project/fapony/wt-fapony",
238
- plan=".fapony/plan/PLAN-verdict-notes.md")
204
+ post. mem_add(kind="bug",
205
+ text="incremental scan replaces cached history with a delta — cache holds 500, truth 1500",
206
+ files=["cache.ts", "claude-code.ts"],
207
+ worktree="/Users/you/Project/fapony/wt-fapony")
239
208
  ```
240
209
 
241
210
  A report in budget — same review that, narrated, ran five paragraphs:
@@ -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
+ }