fapony 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -44
- package/fapony.ts +32 -1
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +13 -1
- package/src/conventions-seed.ts +0 -1
- package/src/db/defaults.ts +1 -1
- package/src/db/getters.ts +3 -3
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +1 -1
- package/src/debt.ts +169 -34
- package/src/digest/collect.ts +16 -0
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +424 -16
- package/src/init-mem.ts +123 -37
- package/src/init.ts +51 -34
- package/src/install/claude.ts +7 -5
- package/src/install/opencode.ts +59 -5
- package/src/lint-baseline.ts +1 -7
- package/src/mcp/evidence.ts +1 -1
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +348 -0
- package/src/memory.ts +237 -40
- package/src/plan-seed.ts +20 -1
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +4 -6
- package/src/stats/data.ts +34 -86
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /package/{templates → src}/mem/selectors.ts +0 -0
package/src/hook.ts
CHANGED
|
@@ -16,14 +16,192 @@
|
|
|
16
16
|
// cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
|
|
17
17
|
// (cursor: loop_count ≥ 1 = the hook already fired, status ≠ completed = allow)
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
appendFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
realpathSync,
|
|
26
|
+
statSync,
|
|
27
|
+
} from "node:fs";
|
|
20
28
|
import { homedir } from "node:os";
|
|
21
|
-
import { basename, join, relative } from "node:path";
|
|
29
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
22
30
|
import { collectSourceFiles, SCAN_EXTS } from "./analyze.js";
|
|
23
31
|
import { openDb } from "./db/index.js";
|
|
24
32
|
import { debtForFile, loadConventions } from "./debt.js";
|
|
25
33
|
import { readMemLog } from "./memory.js";
|
|
26
34
|
|
|
35
|
+
// --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
|
|
36
|
+
//
|
|
37
|
+
// Append-only JSONL under state dir (<faponyDir>/hint-log/<key>.jsonl),
|
|
38
|
+
// one file per worktree. Best-effort: every error swallowed — a hook that
|
|
39
|
+
// cannot log must still annotate. Called at caller only (cmdHookReadHint +
|
|
40
|
+
// opencode plugins), never inside readHintFor/readContextLines/commitHintFor
|
|
41
|
+
// (test pollution: those functions are called ~20x in test/hook.test.ts
|
|
42
|
+
// without setting FAPONY_STATE_DIR).
|
|
43
|
+
|
|
44
|
+
const HINT_LOG_DIR = "hint-log";
|
|
45
|
+
|
|
46
|
+
/** Stable filename key from an absolute worktree path. */
|
|
47
|
+
export function worktreeKey(worktree: string): string {
|
|
48
|
+
return worktree.replace(/^\/+/, "").replace(/\//g, "--");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Directory holding one hint-fire log file per worktree. */
|
|
52
|
+
function hintLogDir(): string {
|
|
53
|
+
const base =
|
|
54
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
55
|
+
return join(base, HINT_LOG_DIR);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Absolute path of a worktree's hint-fire log — may not exist. */
|
|
59
|
+
export function hintLogPath(worktree: string): string {
|
|
60
|
+
return join(hintLogDir(), `${worktreeKey(worktree)}.jsonl`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface HintFireRow {
|
|
64
|
+
ts: string;
|
|
65
|
+
worktree: string;
|
|
66
|
+
surface: "read" | "debt" | "mem" | "commit";
|
|
67
|
+
file: string | null;
|
|
68
|
+
count: number;
|
|
69
|
+
ids?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Append a hint-fire log row. Best-effort: never throws, never blocks.
|
|
74
|
+
* Uses $FAPONY_STATE_DIR when set (tests, CI), otherwise ~/.config/fapony.
|
|
75
|
+
*/
|
|
76
|
+
export function recordHintFire(row: HintFireRow): void {
|
|
77
|
+
try {
|
|
78
|
+
const dir = hintLogDir();
|
|
79
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
80
|
+
appendFileSync(
|
|
81
|
+
hintLogPath(row.worktree),
|
|
82
|
+
`${JSON.stringify(row)}\n`,
|
|
83
|
+
"utf-8",
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort — swallow
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Debt precision (PLAN-feedback-surface chunk 2) ---
|
|
91
|
+
//
|
|
92
|
+
// Reads the hint-fire log, re-runs debtForFile at HEAD for each file that
|
|
93
|
+
// received debt hints, and counts which ids are no longer flagged. This is
|
|
94
|
+
// deterministic (no proxy, no join with events) and answers: of the debt
|
|
95
|
+
// lines fapony showed, how many is the repo now clean of?
|
|
96
|
+
|
|
97
|
+
export interface HintImpact {
|
|
98
|
+
fired: number;
|
|
99
|
+
by_surface: { read: number; debt: number; mem: number; commit: number };
|
|
100
|
+
debt: { shown: number; resolved: number; unknown: number };
|
|
101
|
+
window: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compute hint-fire impact from the log. `since` is an ISO date string;
|
|
106
|
+
* omit to scan all rows. `worktree` scopes to one project's log file (the
|
|
107
|
+
* `<key>.jsonl` naming makes this a filename comparison) — omit to merge
|
|
108
|
+
* every worktree. Returns zeroed counts (not null) when there are no rows —
|
|
109
|
+
* the caller decides how to present "no data" vs "zero".
|
|
110
|
+
*/
|
|
111
|
+
export function computeHintImpact(
|
|
112
|
+
since?: string,
|
|
113
|
+
worktree?: string,
|
|
114
|
+
): HintImpact {
|
|
115
|
+
const dir = hintLogDir();
|
|
116
|
+
const impact: HintImpact = {
|
|
117
|
+
fired: 0,
|
|
118
|
+
by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
|
|
119
|
+
debt: { shown: 0, resolved: 0, unknown: 0 },
|
|
120
|
+
window: since ?? null,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (!existsSync(dir)) return impact;
|
|
124
|
+
|
|
125
|
+
// Read the worktree's .jsonl when scoped, else every file in the dir.
|
|
126
|
+
let files: string[];
|
|
127
|
+
try {
|
|
128
|
+
const all = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
129
|
+
files = worktree
|
|
130
|
+
? all.filter((f) => f === `${worktreeKey(worktree)}.jsonl`)
|
|
131
|
+
: all;
|
|
132
|
+
} catch {
|
|
133
|
+
return impact;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// debtShown: Map<"worktree\tfile\tid", true> — unique debt ids per file.
|
|
137
|
+
const debtShown = new Map<string, true>();
|
|
138
|
+
// debtByFile: Map<"worktree\tfile", string[]> — all ids shown per file.
|
|
139
|
+
const debtByFile = new Map<string, string[]>();
|
|
140
|
+
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
let content: string;
|
|
143
|
+
try {
|
|
144
|
+
content = readFileSync(join(dir, file), "utf-8");
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const line of content.split("\n")) {
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
let row: HintFireRow;
|
|
151
|
+
try {
|
|
152
|
+
row = JSON.parse(line) as HintFireRow;
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (since && row.ts < since) continue;
|
|
157
|
+
impact.fired++;
|
|
158
|
+
impact.by_surface[row.surface]++;
|
|
159
|
+
|
|
160
|
+
if (row.surface === "debt" && row.ids && row.file) {
|
|
161
|
+
const key = `${row.worktree}\t${row.file}`;
|
|
162
|
+
const existing = debtByFile.get(key) ?? [];
|
|
163
|
+
for (const id of row.ids) {
|
|
164
|
+
const dk = `${row.worktree}\t${row.file}\t${id}`;
|
|
165
|
+
if (!debtShown.has(dk)) {
|
|
166
|
+
debtShown.set(dk, true);
|
|
167
|
+
existing.push(id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
debtByFile.set(key, existing);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Re-run debtForFile at HEAD for each file that had debt hints.
|
|
176
|
+
for (const [key, ids] of debtByFile) {
|
|
177
|
+
const [worktree, file] = key.split("\t");
|
|
178
|
+
const absFile = join(worktree, file);
|
|
179
|
+
let currentIds: Set<string>;
|
|
180
|
+
try {
|
|
181
|
+
if (!statSync(absFile).isFile()) {
|
|
182
|
+
// File deleted — all its debt ids are unknown.
|
|
183
|
+
impact.debt.unknown += ids.length;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const convs = debtForFile(worktree, absFile, loadConventions(worktree));
|
|
187
|
+
currentIds = new Set(convs.map((c) => c.id));
|
|
188
|
+
} catch {
|
|
189
|
+
impact.debt.unknown += ids.length;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const id of ids) {
|
|
193
|
+
impact.debt.shown++;
|
|
194
|
+
if (currentIds.has(id)) {
|
|
195
|
+
// still present — not resolved
|
|
196
|
+
} else {
|
|
197
|
+
impact.debt.resolved++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return impact;
|
|
203
|
+
}
|
|
204
|
+
|
|
27
205
|
export interface RawStopPayload {
|
|
28
206
|
// Claude Code
|
|
29
207
|
cwd?: string;
|
|
@@ -319,6 +497,142 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
319
497
|
}
|
|
320
498
|
}
|
|
321
499
|
|
|
500
|
+
// --- Re-read tracking (mtime heuristic — annotate only) ---
|
|
501
|
+
//
|
|
502
|
+
// The size hint above fires on almost nothing real: measured 2026-09-20 over
|
|
503
|
+
// 28,151 read parts across every project, only 2.3% had fileSize>=24KB with a
|
|
504
|
+
// full bound, while 55.5% of read context came from files under 24KB and 19.5%
|
|
505
|
+
// from re-reading a path already read this session (39.2% of context sits in
|
|
506
|
+
// (session,path) pairs read >=2x). The cost is repetition, not one big file —
|
|
507
|
+
// so the gate here is not size, it is "same file, unchanged, again".
|
|
508
|
+
//
|
|
509
|
+
// mtime is the whole mechanism: unchanged mtime since the previous read in
|
|
510
|
+
// this session = the same bytes = annotate; mtime moved = someone edited it =
|
|
511
|
+
// new content = silent. No Edit/Grep tracking — mtime already answers "did it
|
|
512
|
+
// change", so no tool-sequence tagging is needed.
|
|
513
|
+
//
|
|
514
|
+
// Log lives beside hint-log but is separate — hint-log counts fires, this
|
|
515
|
+
// records reads keyed by session (one file per session, so a hint never leaks
|
|
516
|
+
// into the next session / rule 11). Same contract as the size hint: annotate
|
|
517
|
+
// only, never block, every unknown → silent, best-effort. Called at the caller
|
|
518
|
+
// only, never inside a pure function (test pollution — same reason
|
|
519
|
+
// recordHintFire is caller-side).
|
|
520
|
+
|
|
521
|
+
const READ_TRACK_DIR = "read-track";
|
|
522
|
+
|
|
523
|
+
export interface ReadTrackRow {
|
|
524
|
+
ts: string;
|
|
525
|
+
path: string;
|
|
526
|
+
mtime: number;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Filename key for a session — basename of a transcript path or a raw id. */
|
|
530
|
+
export function sessionKey(session: string): string {
|
|
531
|
+
const base = basename(session).replace(/\.[^.]+$/, "");
|
|
532
|
+
return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Directory holding one read log per session. */
|
|
536
|
+
function readTrackDir(): string {
|
|
537
|
+
const base =
|
|
538
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
539
|
+
return join(base, READ_TRACK_DIR);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Absolute path of a session's read log — may not exist. */
|
|
543
|
+
export function readTrackPath(session: string): string {
|
|
544
|
+
return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function readTrackRows(session: string): ReadTrackRow[] {
|
|
548
|
+
const p = readTrackPath(session);
|
|
549
|
+
if (!existsSync(p)) return [];
|
|
550
|
+
const rows: ReadTrackRow[] = [];
|
|
551
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
552
|
+
if (!line) continue;
|
|
553
|
+
try {
|
|
554
|
+
const r = JSON.parse(line) as ReadTrackRow;
|
|
555
|
+
if (typeof r.path === "string" && typeof r.mtime === "number") {
|
|
556
|
+
rows.push(r);
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
// a torn line must not lose the rest of the log
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return rows;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function appendReadTrackRow(session: string, row: ReadTrackRow): void {
|
|
566
|
+
const dir = readTrackDir();
|
|
567
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
568
|
+
appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export interface RereadHintInput {
|
|
572
|
+
filePath: unknown;
|
|
573
|
+
offset?: unknown;
|
|
574
|
+
limit?: unknown;
|
|
575
|
+
cwd: string;
|
|
576
|
+
/** Session identity — transcript path (Claude) or session id (OpenCode). */
|
|
577
|
+
session?: unknown;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Annotate a full-file read of a path already read this session whose mtime
|
|
582
|
+
* has not moved, or null. Records every full read, so the returned count is
|
|
583
|
+
* the number of prior reads. Every unknown (kill switch on, no session, no
|
|
584
|
+
* path, bounded read, partial offset, missing file, read/write failure)
|
|
585
|
+
* resolves to null — a hint must never fire on a guess.
|
|
586
|
+
*/
|
|
587
|
+
export function rereadHintFor(opts: RereadHintInput): string | null {
|
|
588
|
+
try {
|
|
589
|
+
if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
|
|
590
|
+
if (typeof opts.session !== "string" || opts.session === "") return null;
|
|
591
|
+
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
592
|
+
// A bounded/partial read is already cheap — same line the size hint draws.
|
|
593
|
+
const offset = typeof opts.offset === "number" ? opts.offset : null;
|
|
594
|
+
if (offset !== null && offset !== 0) return null;
|
|
595
|
+
const limit = typeof opts.limit === "number" ? opts.limit : null;
|
|
596
|
+
if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
|
|
597
|
+
|
|
598
|
+
const abs = (() => {
|
|
599
|
+
const p = opts.filePath.startsWith("/")
|
|
600
|
+
? opts.filePath
|
|
601
|
+
: join(opts.cwd, opts.filePath);
|
|
602
|
+
try {
|
|
603
|
+
return realpathSync(p);
|
|
604
|
+
} catch {
|
|
605
|
+
return resolve(p);
|
|
606
|
+
}
|
|
607
|
+
})();
|
|
608
|
+
const st = statSync(abs);
|
|
609
|
+
if (!st.isFile()) return null;
|
|
610
|
+
const mtime = Math.round(st.mtimeMs);
|
|
611
|
+
|
|
612
|
+
const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
|
|
613
|
+
const last = prior.at(-1);
|
|
614
|
+
appendReadTrackRow(opts.session, {
|
|
615
|
+
ts: new Date().toISOString(),
|
|
616
|
+
path: abs,
|
|
617
|
+
mtime,
|
|
618
|
+
});
|
|
619
|
+
if (!last || last.mtime !== mtime) return null;
|
|
620
|
+
|
|
621
|
+
// The hint feeds the agent's context — show the path *it* passed, relative
|
|
622
|
+
// to its own cwd, so a symlinked cwd (macOS /var → /private/var) does not
|
|
623
|
+
// turn a clean relative path into a resolved absolute one.
|
|
624
|
+
const rel = relative(opts.cwd, opts.filePath);
|
|
625
|
+
const shown = rel.startsWith("..") ? opts.filePath : rel;
|
|
626
|
+
return (
|
|
627
|
+
`fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
|
|
628
|
+
`content unchanged since the last read (mtime), grep the line range ` +
|
|
629
|
+
`you need instead of re-reading it`
|
|
630
|
+
);
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
322
636
|
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
323
637
|
//
|
|
324
638
|
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
@@ -417,6 +731,8 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
417
731
|
try {
|
|
418
732
|
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
419
733
|
cwd?: string;
|
|
734
|
+
transcript_path?: string;
|
|
735
|
+
session_id?: string;
|
|
420
736
|
tool_input?: {
|
|
421
737
|
file_path?: unknown;
|
|
422
738
|
offset?: unknown;
|
|
@@ -424,16 +740,31 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
424
740
|
};
|
|
425
741
|
};
|
|
426
742
|
const cwd = raw.cwd ?? process.cwd();
|
|
743
|
+
const filePath = raw.tool_input?.file_path;
|
|
744
|
+
// One read log per session — transcript_path is the Claude session file,
|
|
745
|
+
// session_id the fallback when a client omits it.
|
|
746
|
+
const session = raw.transcript_path ?? raw.session_id;
|
|
427
747
|
const parts: string[] = [];
|
|
428
748
|
const hint = readHintFor({
|
|
429
|
-
filePath
|
|
749
|
+
filePath,
|
|
430
750
|
offset: raw.tool_input?.offset,
|
|
431
751
|
limit: raw.tool_input?.limit,
|
|
432
752
|
cwd,
|
|
433
753
|
});
|
|
434
754
|
if (hint) parts.push(hint);
|
|
435
|
-
|
|
436
|
-
|
|
755
|
+
const reread = rereadHintFor({
|
|
756
|
+
filePath,
|
|
757
|
+
offset: raw.tool_input?.offset,
|
|
758
|
+
limit: raw.tool_input?.limit,
|
|
759
|
+
cwd,
|
|
760
|
+
session,
|
|
761
|
+
});
|
|
762
|
+
if (reread) parts.push(reread);
|
|
763
|
+
const ctx = readContextData(filePath, cwd);
|
|
764
|
+
if (ctx) {
|
|
765
|
+
for (const line of [...ctx.debtLines, ...ctx.memLines]) {
|
|
766
|
+
parts.push(line);
|
|
767
|
+
}
|
|
437
768
|
}
|
|
438
769
|
if (parts.length > 0) {
|
|
439
770
|
console.log(
|
|
@@ -445,6 +776,60 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
445
776
|
}),
|
|
446
777
|
);
|
|
447
778
|
}
|
|
779
|
+
|
|
780
|
+
// --- hint-fire log (PLAN-feedback-surface chunk 1) ---
|
|
781
|
+
// After output — best-effort, never block the hint.
|
|
782
|
+
const rel =
|
|
783
|
+
typeof filePath === "string"
|
|
784
|
+
? (() => {
|
|
785
|
+
try {
|
|
786
|
+
const git = Bun.spawnSync(
|
|
787
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
788
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
789
|
+
);
|
|
790
|
+
if (git.exitCode !== 0) return null;
|
|
791
|
+
const wt = realpathSync(git.stdout.toString().trim());
|
|
792
|
+
const abs = realpathSync(
|
|
793
|
+
filePath.startsWith("/") ? filePath : join(wt, filePath),
|
|
794
|
+
);
|
|
795
|
+
const r = relative(wt, abs).split("\\").join("/");
|
|
796
|
+
return r.startsWith("..") ? null : r;
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
})()
|
|
801
|
+
: null;
|
|
802
|
+
const worktree = ctx?.worktree ?? null;
|
|
803
|
+
if (worktree) {
|
|
804
|
+
if (hint) {
|
|
805
|
+
recordHintFire({
|
|
806
|
+
ts: new Date().toISOString(),
|
|
807
|
+
worktree,
|
|
808
|
+
surface: "read",
|
|
809
|
+
file: rel,
|
|
810
|
+
count: 1,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (ctx && ctx.debtIds.length > 0) {
|
|
814
|
+
recordHintFire({
|
|
815
|
+
ts: new Date().toISOString(),
|
|
816
|
+
worktree,
|
|
817
|
+
surface: "debt",
|
|
818
|
+
file: rel,
|
|
819
|
+
count: ctx.debtIds.length,
|
|
820
|
+
ids: ctx.debtIds,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (ctx && ctx.memLines.length > 0) {
|
|
824
|
+
recordHintFire({
|
|
825
|
+
ts: new Date().toISOString(),
|
|
826
|
+
worktree,
|
|
827
|
+
surface: "mem",
|
|
828
|
+
file: rel,
|
|
829
|
+
count: ctx.memLines.length,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
448
833
|
} catch {
|
|
449
834
|
// any failure = no hint; a hook must never block a read over a hint
|
|
450
835
|
}
|
|
@@ -462,15 +847,26 @@ const DEBT_HINT_MAX = 3;
|
|
|
462
847
|
const MEM_HINT_MAX = 2;
|
|
463
848
|
const MEM_TEXT_MAX = 120;
|
|
464
849
|
|
|
465
|
-
export
|
|
850
|
+
export interface ContextLineData {
|
|
851
|
+
worktree: string;
|
|
852
|
+
debtIds: string[];
|
|
853
|
+
debtLines: string[];
|
|
854
|
+
memLines: string[];
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
|
|
858
|
+
export function readContextData(
|
|
859
|
+
filePath: unknown,
|
|
860
|
+
cwd: string,
|
|
861
|
+
): ContextLineData | null {
|
|
466
862
|
try {
|
|
467
|
-
if (typeof filePath !== "string" || filePath === "") return
|
|
863
|
+
if (typeof filePath !== "string" || filePath === "") return null;
|
|
468
864
|
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
469
865
|
cwd,
|
|
470
866
|
stdout: "pipe",
|
|
471
867
|
stderr: "pipe",
|
|
472
868
|
});
|
|
473
|
-
if (git.exitCode !== 0) return
|
|
869
|
+
if (git.exitCode !== 0) return null;
|
|
474
870
|
// macOS /var → /private/var: git reports the resolved root while callers
|
|
475
871
|
// pass unresolved tmp paths — normalize both sides before comparing.
|
|
476
872
|
const worktree = realpathSync(git.stdout.toString().trim());
|
|
@@ -478,9 +874,11 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
|
478
874
|
filePath.startsWith("/") ? filePath : join(worktree, filePath),
|
|
479
875
|
);
|
|
480
876
|
const rel = relative(worktree, abs).split("\\").join("/");
|
|
481
|
-
if (rel.startsWith("..") || rel === "") return
|
|
877
|
+
if (rel.startsWith("..") || rel === "") return null;
|
|
482
878
|
|
|
483
|
-
const
|
|
879
|
+
const debtIds: string[] = [];
|
|
880
|
+
const debtLines: string[] = [];
|
|
881
|
+
const memLines: string[] = [];
|
|
484
882
|
|
|
485
883
|
// convention debt — source files only, fresh from the repo
|
|
486
884
|
const dot = rel.lastIndexOf(".");
|
|
@@ -490,7 +888,8 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
|
490
888
|
abs,
|
|
491
889
|
loadConventions(worktree),
|
|
492
890
|
).slice(0, DEBT_HINT_MAX)) {
|
|
493
|
-
|
|
891
|
+
debtIds.push(c.id);
|
|
892
|
+
debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
|
|
494
893
|
}
|
|
495
894
|
}
|
|
496
895
|
|
|
@@ -520,15 +919,24 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
|
520
919
|
if (sameName !== 1) usableBase = [];
|
|
521
920
|
}
|
|
522
921
|
// direct hits (files[] / full path) outrank bare-basename hits
|
|
523
|
-
const
|
|
524
|
-
for (const r of
|
|
525
|
-
|
|
922
|
+
const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
|
|
923
|
+
for (const r of memHits) {
|
|
924
|
+
memLines.push(
|
|
526
925
|
`fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
|
|
527
926
|
);
|
|
528
927
|
}
|
|
529
928
|
}
|
|
530
|
-
return
|
|
929
|
+
return { worktree, debtIds, debtLines, memLines };
|
|
531
930
|
} catch {
|
|
532
|
-
return
|
|
931
|
+
return null;
|
|
533
932
|
}
|
|
534
933
|
}
|
|
934
|
+
|
|
935
|
+
export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
936
|
+
const data = readContextData(filePath, cwd);
|
|
937
|
+
if (!data) return [];
|
|
938
|
+
return [...data.debtLines, ...data.memLines].slice(
|
|
939
|
+
0,
|
|
940
|
+
DEBT_HINT_MAX + MEM_HINT_MAX,
|
|
941
|
+
);
|
|
942
|
+
}
|