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.
- package/fapony.ts +3 -116
- package/package.json +2 -2
- package/skill/move-to-done/SKILL.md +3 -2
- package/src/adapters/cli.ts +123 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +79 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +302 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
- package/src/analyze.ts +1 -1
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +2 -2
- package/src/gates.ts +1 -1
- package/src/hook.ts +69 -1424
- package/src/init-mem.ts +1 -1
- package/src/init.ts +1 -1
- package/src/install/claude.ts +17 -0
- package/src/install/opencode.ts +114 -0
- package/src/install.ts +12 -5
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +70 -42
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +24 -387
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/seed/plan-seed.ts +8 -4
- package/src/seed/primitives.ts +1 -7
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +3 -3
- package/src/telemetry.ts +2 -2
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -93
- package/src/web/html.ts +2 -33
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/mcp/types.ts +0 -54
- /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
- /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
|
|
4
|
+
// CLI dispatch lives in src/adapters/cli.ts
|
|
5
5
|
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
+
"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": "
|
|
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
|
|
44
|
-
|
|
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
|
+
}
|