fapony 0.3.3 → 0.3.5

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 (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
package/README.md CHANGED
@@ -476,6 +476,13 @@ fapony mem where # show the resolved mem dir and which
476
476
  fapony mem done | stale # views
477
477
  fapony debt [--id <convention>] [--where <path>] # ไฟล์ไหนยังไม่ย้ายไป convention ที่ประกาศไว้ (live, read-only)
478
478
  fapony lint-baseline [--cmd ...] [--diff] # separate "already red" from "I made it red"
479
+
480
+ # Hooks (wired by `fapony install`, not run by hand)
481
+ fapony hook-stop # Stop hook: block turns with commits but no mem row
482
+ fapony hook-read-hint # read/re-read annotations
483
+ fapony hook-edit-hint # importer count before editing shape
484
+ fapony hook-mv-guard # deny raw git mv of plan files into done/
485
+ fapony hook-session-start # SessionStart: kickoff into context
479
486
  ```
480
487
 
481
488
  *When* to call `mem add` is your project's call, not fapony's — write it in your own
@@ -501,7 +508,6 @@ fapony install --dry-run # show what would happen without writi
501
508
  fapony setup # interactive wizard: config + scaffold in one step
502
509
  fapony update # self-update via git pull
503
510
  fapony telemetry show|send # opt-in only, default off — see https://github.com/kire21b/fapony/blob/main/TELEMETRY.md
504
- fapony test # self-check
505
511
  ```
506
512
 
507
513
  ## Config
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.5",
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",
@@ -29,11 +29,10 @@
29
29
  "scripts": {
30
30
  "lint": "biome check .",
31
31
  "typecheck": "tsc --noEmit",
32
- "test": "bun fapony.ts test",
33
- "test:fast": "SKIP_SLOW=1 bun fapony.ts test",
34
- "test:one": "bun scripts/test-one.ts",
32
+ "test": "bun test --timeout 20000 --parallel",
33
+ "test:changed": "bun test --timeout 20000 --changed",
35
34
  "knip": "bunx knip@6 --exclude types,nsTypes || true",
36
- "check": "bun run lint && bun run typecheck && bun fapony.ts test",
35
+ "check": "bun run lint && bun run typecheck && bun test --timeout 20000 --parallel",
37
36
  "prepublishOnly": "bash scripts/smoke-publish.sh",
38
37
  "release": "git checkout main && git pull --ff-only && npm version patch -m 'release v%s' && git push origin main --follow-tags",
39
38
  "overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
@@ -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,128 @@
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 { renderUsage, suggestCommand } from "../commands.js";
10
+ import { cmdDebt } from "../debt/cli.js";
11
+ import { cmdDigest } from "../digest/cli.js";
12
+ import { cmdInit } from "../init.js";
13
+ import { cmdInitMem } from "../init-mem.js";
14
+ import { cmdInstall } from "../install.js";
15
+ import { cmdLintBaseline } from "../lint-baseline.js";
16
+ import { cmdMem, MEM_SUBCOMMANDS } from "../mem/index.js";
17
+ import { initStore } from "../mem/store.js";
18
+ import { cmdPriceScan } from "../price/index.js";
19
+ import { cmdReport, cmdReportWeb } from "../report/index.js";
20
+ import { cmdPlanSeed } from "../seed/plan-seed.js";
21
+ import { cmdReviewSeed } from "../seed/review-seed.js";
22
+ import { cmdSetup } from "../setup.js";
23
+ import { cmdStats } from "../stats/index.js";
24
+ import { cmdTelemetry } from "../telemetry.js";
25
+ import { cmdUpdate } from "../update.js";
26
+ import { cmdUsageScan, cmdUsageWeb } from "../usage/index.js";
27
+ import {
28
+ cmdHookEditHint,
29
+ cmdHookMvGuard,
30
+ cmdHookReadHint,
31
+ cmdHookSessionStart,
32
+ cmdHookStop,
33
+ } from "./hooks/index.js";
34
+ import { cmdMcp } from "./mcp/transport.js";
35
+
36
+ export async function cliMain(): Promise<void> {
37
+ const [cmd, ...a] = process.argv.slice(2);
38
+
39
+ if (!cmd || cmd === "--help" || cmd === "-h") {
40
+ console.log(renderUsage());
41
+ return;
42
+ }
43
+
44
+ if (cmd === "analyze") {
45
+ cmdAnalyze(a);
46
+ } else if (cmd === "debt") {
47
+ cmdDebt(a);
48
+ } else if (cmd === "lint-baseline") {
49
+ cmdLintBaseline(a);
50
+ } else if (cmd === "plan-seed") {
51
+ cmdPlanSeed(a);
52
+ } else if (cmd === "review-seed") {
53
+ cmdReviewSeed(a);
54
+ } else if (cmd === "digest") {
55
+ await cmdDigest(a);
56
+ } else if (cmd === "stats") {
57
+ cmdStats(a);
58
+ } else if (cmd === "telemetry") {
59
+ await cmdTelemetry(a);
60
+ } else if (cmd === "init-mem") {
61
+ cmdInitMem(a);
62
+ } else if (cmd === "mem") {
63
+ const memDirIdx = a.indexOf("--mem-dir");
64
+ let overrideMemDir: string | undefined;
65
+ let rest = a;
66
+ if (memDirIdx !== -1) {
67
+ overrideMemDir = a[memDirIdx + 1];
68
+ if (!overrideMemDir || overrideMemDir.startsWith("--")) {
69
+ console.error("fapony mem: --mem-dir needs a value");
70
+ process.exit(1);
71
+ }
72
+ if (!existsSync(overrideMemDir)) {
73
+ console.error(
74
+ `fapony mem: --mem-dir path does not exist: ${overrideMemDir}`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+ rest = a.filter((_, i) => i !== memDirIdx && i !== memDirIdx + 1);
79
+ }
80
+ initStore(process.cwd(), overrideMemDir);
81
+ try {
82
+ await cmdMem(rest, overrideMemDir);
83
+ } catch (e) {
84
+ console.error(
85
+ `fapony mem: ${e instanceof Error ? e.message : String(e)}`,
86
+ );
87
+ process.exit(1);
88
+ }
89
+ } else if (cmd === "init") {
90
+ await cmdInit(a);
91
+ } else if (cmd === "install") {
92
+ await cmdInstall(a);
93
+ } else if (cmd === "setup") {
94
+ await cmdSetup();
95
+ } else if (cmd === "update") {
96
+ await cmdUpdate();
97
+ } else if (cmd === "hook-stop") {
98
+ await cmdHookStop();
99
+ } else if (cmd === "hook-read-hint") {
100
+ await cmdHookReadHint();
101
+ } else if (cmd === "hook-edit-hint") {
102
+ await cmdHookEditHint();
103
+ } else if (cmd === "hook-mv-guard") {
104
+ await cmdHookMvGuard();
105
+ } else if (cmd === "hook-session-start") {
106
+ await cmdHookSessionStart();
107
+ } else if (cmd === "mcp") {
108
+ cmdMcp();
109
+ } else if (cmd === "report") {
110
+ cmdReport(a);
111
+ } else if (cmd === "report-web") {
112
+ cmdReportWeb(a);
113
+ } else if (cmd === "usage-scan") {
114
+ cmdUsageScan(a);
115
+ } else if (cmd === "price-scan") {
116
+ await cmdPriceScan(a);
117
+ } else if (cmd === "usage-web") {
118
+ cmdUsageWeb(a);
119
+ } else {
120
+ console.error(`fapony: unknown command "${cmd ?? ""}"`);
121
+ const hints = suggestCommand(cmd ?? "", MEM_SUBCOMMANDS);
122
+ if (hints.length > 0) {
123
+ console.error(`did you mean ${hints.map((h) => `"${h}"`).join(" or ")}?`);
124
+ }
125
+ console.error(`usage: fapony <command> [args] — see "fapony --help"`);
126
+ process.exit(1);
127
+ }
128
+ }
@@ -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
+ }