fapony 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/fapony.ts +3 -116
  2. package/package.json +2 -2
  3. package/skill/move-to-done/SKILL.md +3 -2
  4. package/src/adapters/cli.ts +123 -0
  5. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  6. package/src/adapters/hooks/context-data.ts +102 -0
  7. package/src/adapters/hooks/edit-hint.ts +195 -0
  8. package/src/adapters/hooks/git-autonomy.ts +178 -0
  9. package/src/adapters/hooks/index.ts +79 -0
  10. package/src/adapters/hooks/mv-guard.ts +52 -0
  11. package/src/adapters/hooks/read-hint.ts +383 -0
  12. package/src/adapters/hooks/session-start.ts +101 -0
  13. package/src/adapters/hooks/stop.ts +302 -0
  14. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  15. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  17. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  19. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  20. package/src/adapters/mcp/types.ts +18 -0
  21. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  22. package/src/analyze.ts +1 -1
  23. package/src/conventions-seed.ts +1 -1
  24. package/src/core/config.ts +199 -0
  25. package/src/core/debt-format.ts +107 -0
  26. package/src/core/debt-types.ts +79 -0
  27. package/src/core/defaults.ts +8 -0
  28. package/src/core/enums.ts +34 -0
  29. package/src/core/format.ts +33 -0
  30. package/src/core/hint-log.ts +68 -0
  31. package/src/core/hook-helpers.ts +31 -0
  32. package/src/core/mem-log.ts +357 -0
  33. package/src/core/parse.ts +71 -0
  34. package/src/core/pricing.ts +217 -0
  35. package/src/core/safety.ts +18 -0
  36. package/src/core/types.ts +142 -0
  37. package/src/core/util.ts +105 -0
  38. package/src/db/store.ts +2 -2
  39. package/src/debt/cli.ts +1 -1
  40. package/src/debt/format.ts +2 -107
  41. package/src/debt/load.ts +1 -1
  42. package/src/debt/promotion.ts +1 -1
  43. package/src/debt/types.ts +14 -79
  44. package/src/digest/collect.ts +4 -3
  45. package/src/gate.ts +2 -2
  46. package/src/gates.ts +1 -1
  47. package/src/hook.ts +69 -1424
  48. package/src/init-mem.ts +1 -1
  49. package/src/init.ts +1 -1
  50. package/src/install/claude.ts +17 -0
  51. package/src/install/opencode.ts +114 -0
  52. package/src/install.ts +12 -5
  53. package/src/lint-baseline.ts +1 -2
  54. package/src/map.ts +29 -8
  55. package/src/mem/commands/plan.ts +70 -42
  56. package/src/mem/store.ts +5 -1
  57. package/src/memory.ts +24 -387
  58. package/src/parse.ts +9 -71
  59. package/src/price/fetch.ts +4 -16
  60. package/src/price/resolve.ts +12 -213
  61. package/src/report/cli.ts +3 -3
  62. package/src/safety.ts +2 -18
  63. package/src/seed/plan-seed.ts +8 -4
  64. package/src/seed/primitives.ts +1 -7
  65. package/src/session/types.ts +14 -128
  66. package/src/setup.ts +1 -1
  67. package/src/stats/data.ts +3 -3
  68. package/src/telemetry.ts +2 -2
  69. package/src/usage/cache.ts +1 -2
  70. package/src/usage/cli.ts +1 -1
  71. package/src/usage/scan.ts +2 -1
  72. package/src/util.ts +10 -93
  73. package/src/web/html.ts +2 -33
  74. package/src/db/defaults.ts +0 -34
  75. package/src/db/getters.ts +0 -35
  76. package/src/db/index.ts +0 -7
  77. package/src/db/load.ts +0 -57
  78. package/src/db/types.ts +0 -77
  79. package/src/mcp/types.ts +0 -54
  80. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  81. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -0,0 +1,178 @@
1
+ // src/adapters/hooks/git-autonomy.ts — git-autonomy rewrites as shared logic.
2
+ //
3
+ // A personal opencode plugin (~/.config/opencode/plugins/git-autonomy.ts)
4
+ // rewrote opencode's built-in "never commit unless asked" policy on two
5
+ // surfaces: the system prompt (experimental.chat.system.transform) and the
6
+ // bash tool description (tool.definition). This module is that file's logic,
7
+ // extracted so `fapony install --platform opencode --git-autonomy` can
8
+ // generate a thin plugin that imports it — one copy, updated by `git pull`,
9
+ // same shape as the read/commit/edit/session-start hints.
10
+ //
11
+ // Opt-in only: the installer never writes this unless the flag is passed.
12
+ // The rewrite itself is an opinion (commit-as-you-go), not a utility, so it
13
+ // must never ride the default install path.
14
+ //
15
+ // Stale-regex guard (the "rots silently" problem): upstream text lives in the
16
+ // opencode binary, not in a file, so a reword upstream makes a pattern stop
17
+ // matching with no error. Every rewrite carries `required`; `gitAutonomyStatus`
18
+ // reports required ids that hit nothing — the analyze-like help for this
19
+ // feature. The tone-example cut is required:false: versions without those
20
+ // examples are fine, a missing commit policy is not.
21
+
22
+ /** Commit-as-you-go policy replacing opencode's ask-first rule. */
23
+ export const GIT_AUTONOMY_COMMIT_POLICY =
24
+ "Commit each finished unit of work as you go — do not wait to be asked. If the user wants to review before a commit they will say so.";
25
+
26
+ /** Same policy, bash-tool-description surface. */
27
+ export const GIT_AUTONOMY_TOOL_POLICY =
28
+ "Commit finished work as you go — do not wait to be asked; never amend published commits or force-push.";
29
+
30
+ export const GIT_AUTONOMY_PLUGIN_FILE = "fapony-git-autonomy.ts";
31
+ export const GIT_AUTONOMY_PLUGIN_NAME = "FaponyGitAutonomy";
32
+
33
+ export interface GitAutonomyRewrite {
34
+ /** Stable id used by gitAutonomyStatus to name the stale pattern. */
35
+ id: string;
36
+ pattern: RegExp;
37
+ replacement: string;
38
+ /** False = a miss is fine (upstream dropped that text). True = a miss is rot. */
39
+ required: boolean;
40
+ }
41
+
42
+ export const GIT_AUTONOMY_SYSTEM_REWRITES: GitAutonomyRewrite[] = [
43
+ {
44
+ id: "system-commit-full",
45
+ pattern: /NEVER commit changes[\s\S]{0,500}?too proactive\./g,
46
+ replacement: GIT_AUTONOMY_COMMIT_POLICY,
47
+ required: true,
48
+ },
49
+ {
50
+ id: "system-commit-fallback",
51
+ pattern: /NEVER commit changes[^.]*\./g,
52
+ replacement: GIT_AUTONOMY_COMMIT_POLICY,
53
+ required: true,
54
+ },
55
+ {
56
+ id: "system-bash-desc",
57
+ pattern:
58
+ /- Only commit, amend, push, or create PRs when explicitly requested\./g,
59
+ replacement:
60
+ "- Commit and push finished work as you go, and open a PR when a branch is ready. Do not amend published commits or force-push.",
61
+ required: true,
62
+ },
63
+ {
64
+ // "# Tone and style" few-shot examples: from the intro down to the next
65
+ // heading. Anchored on the unique intro so no other <example> block is
66
+ // touched. Pure token rent — the surrounding sentences already state the
67
+ // terseness rules.
68
+ id: "system-tone-examples",
69
+ pattern:
70
+ /Here are some examples to demonstrate appropriate verbosity:[\s\S]*?(?=\n# )/g,
71
+ replacement: "",
72
+ required: false,
73
+ },
74
+ ];
75
+
76
+ export interface GitAutonomyToolRewrite extends GitAutonomyRewrite {
77
+ toolID: string;
78
+ }
79
+
80
+ export const GIT_AUTONOMY_TOOL_REWRITES: GitAutonomyToolRewrite[] = [
81
+ {
82
+ id: "tool-bash-commit",
83
+ toolID: "bash",
84
+ pattern:
85
+ /- Only commit, amend, push, or create PRs when explicitly requested\./g,
86
+ replacement: `- ${GIT_AUTONOMY_TOOL_POLICY}`,
87
+ required: true,
88
+ },
89
+ ];
90
+
91
+ function fresh(re: RegExp): RegExp {
92
+ // /g patterns are stateful via lastIndex — clone so repeated calls and
93
+ // .test-then-.replace sequences never skip a match.
94
+ return new RegExp(re.source, re.flags);
95
+ }
96
+
97
+ /**
98
+ * Apply the system-prompt rewrites. Returns the rewritten text plus the ids
99
+ * that fired, in order — the caller (plugin) ignores the second half, tests
100
+ * and gitAutonomyStatus use it to tell "rewrote" from "silently missed".
101
+ */
102
+ export function rewriteGitAutonomySystem(text: string): {
103
+ text: string;
104
+ matched: string[];
105
+ } {
106
+ const matched: string[] = [];
107
+ let out = text;
108
+ for (const r of GIT_AUTONOMY_SYSTEM_REWRITES) {
109
+ const re = fresh(r.pattern);
110
+ if (re.test(out)) {
111
+ matched.push(r.id);
112
+ out = out.replace(fresh(r.pattern), r.replacement);
113
+ // The loose fallback must never run once the full block was replaced.
114
+ if (r.id === "system-commit-full") break;
115
+ }
116
+ }
117
+ return { text: out, matched };
118
+ }
119
+
120
+ /** Apply the tool.description rewrites for one toolID. */
121
+ export function rewriteGitAutonomyTool(
122
+ toolID: string,
123
+ description: string,
124
+ ): { description: string; matched: string[] } {
125
+ const matched: string[] = [];
126
+ let out = description;
127
+ for (const r of GIT_AUTONOMY_TOOL_REWRITES) {
128
+ if (r.toolID !== toolID) continue;
129
+ const re = fresh(r.pattern);
130
+ if (re.test(out)) {
131
+ matched.push(r.id);
132
+ out = out.replace(fresh(r.pattern), r.replacement);
133
+ }
134
+ }
135
+ return { description: out, matched };
136
+ }
137
+
138
+ export interface GitAutonomyStatus {
139
+ /** Required ids that fired at least once across the inputs. */
140
+ hits: string[];
141
+ /** Required ids that hit nothing — upstream reworded or moved the text. */
142
+ stale: string[];
143
+ /** True when no required pattern is stale. */
144
+ ok: boolean;
145
+ }
146
+
147
+ /**
148
+ * Analyze-like help for this feature: feed the current opencode system lines
149
+ * + tool descriptions, get back which required patterns are stale. Optional
150
+ * rewrites (tone examples) never appear in `stale` — their absence is fine.
151
+ */
152
+ export function gitAutonomyStatus(
153
+ systemTexts: string[],
154
+ toolDescs: Record<string, string>,
155
+ ): GitAutonomyStatus {
156
+ const seen = new Set<string>();
157
+ for (const t of systemTexts) {
158
+ for (const id of rewriteGitAutonomySystem(t).matched) seen.add(id);
159
+ }
160
+ for (const [toolID, desc] of Object.entries(toolDescs)) {
161
+ for (const id of rewriteGitAutonomyTool(toolID, desc).matched) seen.add(id);
162
+ }
163
+ const requiredIds = new Set<string>();
164
+ for (const r of GIT_AUTONOMY_SYSTEM_REWRITES)
165
+ if (r.required) requiredIds.add(r.id);
166
+ for (const r of GIT_AUTONOMY_TOOL_REWRITES)
167
+ if (r.required) requiredIds.add(r.id);
168
+ // system-commit-full and system-commit-fallback are two spellings of one
169
+ // policy: either hit means the policy landed, only both missing is stale.
170
+ const commitHit =
171
+ seen.has("system-commit-full") || seen.has("system-commit-fallback");
172
+ const hits = [...seen].filter((id) => requiredIds.has(id));
173
+ const stale: string[] = [];
174
+ if (!commitHit) stale.push("system-commit");
175
+ if (!seen.has("system-bash-desc")) stale.push("system-bash-desc");
176
+ if (!seen.has("tool-bash-commit")) stale.push("tool-bash-commit");
177
+ return { hits, stale, ok: stale.length === 0 };
178
+ }
@@ -0,0 +1,79 @@
1
+ // src/adapters/hooks/index.ts — re-export all hook adapters
2
+
3
+ // Re-export hint-log for backwards compatibility.
4
+ export {
5
+ type HintFireRow,
6
+ type HintImpact,
7
+ hintLogDir,
8
+ hintLogPath,
9
+ recordHintFire,
10
+ worktreeKey,
11
+ } from "../../core/hint-log.js";
12
+ // Re-export pure helpers from core for backwards compatibility.
13
+ export {
14
+ hookTsMs,
15
+ sessionKey,
16
+ utcStamp,
17
+ } from "../../core/hook-helpers.js";
18
+ export { computeHintImpact } from "./compute-hint-impact.js";
19
+ export {
20
+ type ContextLineData,
21
+ readContextData,
22
+ readContextLines,
23
+ } from "./context-data.js";
24
+ export {
25
+ cmdHookEditHint,
26
+ type EditHintInput,
27
+ type EditTrackRow,
28
+ editHintFor,
29
+ editTrackPath,
30
+ } from "./edit-hint.js";
31
+ export {
32
+ GIT_AUTONOMY_COMMIT_POLICY,
33
+ GIT_AUTONOMY_PLUGIN_FILE,
34
+ GIT_AUTONOMY_PLUGIN_NAME,
35
+ GIT_AUTONOMY_SYSTEM_REWRITES,
36
+ GIT_AUTONOMY_TOOL_POLICY,
37
+ GIT_AUTONOMY_TOOL_REWRITES,
38
+ type GitAutonomyRewrite,
39
+ type GitAutonomyStatus,
40
+ type GitAutonomyToolRewrite,
41
+ gitAutonomyStatus,
42
+ rewriteGitAutonomySystem,
43
+ rewriteGitAutonomyTool,
44
+ } from "./git-autonomy.js";
45
+ export { cmdHookMvGuard, mvGuardDecision } from "./mv-guard.js";
46
+ export {
47
+ COMMIT_HINT_MIN_COMMITS,
48
+ type CommitHintInput,
49
+ cmdHookReadHint,
50
+ commitHintFor,
51
+ READ_HINT_MIN_BYTES,
52
+ READ_HINT_MIN_LIMIT,
53
+ type ReadHintInput,
54
+ type ReadTrackRow,
55
+ type RereadHintInput,
56
+ readHintFor,
57
+ readTrackPath,
58
+ rereadHintFor,
59
+ } from "./read-hint.js";
60
+ export {
61
+ capContext,
62
+ cmdHookSessionStart,
63
+ SESSION_START_MAX_CHARS,
64
+ sessionStartContext,
65
+ } from "./session-start.js";
66
+ export {
67
+ cmdHookStop,
68
+ cursorTranscriptPath,
69
+ decideStop,
70
+ isCodexPayload,
71
+ isCursorPayload,
72
+ type NormalizedStopInput,
73
+ normalizeStopInput,
74
+ type RawStopPayload,
75
+ type StopClient,
76
+ stopBlockedBefore,
77
+ stopBlockPath,
78
+ stopOutput,
79
+ } from "./stop.js";
@@ -0,0 +1,52 @@
1
+ // src/adapters/hooks/mv-guard.ts — PreToolUse Bash guard: deny a raw `git mv`
2
+ // of a plan file into a done/ directory, point at `fapony mem plan-sweep
3
+ // --apply` instead.
4
+ //
5
+ // Manual `git mv` skips the link rewrite plan-sweep does — that produced two
6
+ // rounds of dangling links (mem mtjn3ldk, mtl15q4y) and once, a plan moved to
7
+ // a path that doesn't exist (.fapony/plan/done/ instead of .fapony/done/).
8
+ // Every other fapony hook only annotates; this one denies, because asking
9
+ // ("should use plan-sweep") measurably does not change agent behavior —
10
+ // only required/reject and Stop-style blocks do (CLAUDE.md rule 9).
11
+ //
12
+ // Claude-only: OpenCode's tool.execute.after fires after the mv already ran,
13
+ // so there is nothing left to deny by the time that hook sees it.
14
+
15
+ const MV_PATTERN =
16
+ /git\s+mv\s+(?:-\S+\s+)*["']?([^"'\s]*\.fapony\/(?:[^/\s]+\/)*plan\/PLAN-[^"'\s]+\.md)["']?\s+["']?([^"'\s]*\bdone\/?)["']?/;
17
+
18
+ /** Deny reason for a raw `git mv <plan>.md <...done/>` command, or null to allow. */
19
+ export function mvGuardDecision(command: unknown): string | null {
20
+ if (typeof command !== "string" || command === "") return null;
21
+ const m = MV_PATTERN.exec(command);
22
+ if (!m) return null;
23
+ const [, src] = m;
24
+ return (
25
+ `fapony: raw \`git mv\` of a plan into done/ skips the link rewrite — ` +
26
+ `run \`fapony mem plan-sweep --apply ${src}\` instead, it moves the file ` +
27
+ `and fixes inbound/outbound links together.`
28
+ );
29
+ }
30
+
31
+ /** Claude Code PreToolUse (matcher Bash): stdin JSON in, deny decision out. */
32
+ export async function cmdHookMvGuard(): Promise<void> {
33
+ try {
34
+ const raw = JSON.parse(await Bun.stdin.text()) as {
35
+ tool_input?: { command?: unknown };
36
+ };
37
+ const reason = mvGuardDecision(raw.tool_input?.command);
38
+ if (reason) {
39
+ console.log(
40
+ JSON.stringify({
41
+ hookSpecificOutput: {
42
+ hookEventName: "PreToolUse",
43
+ permissionDecision: "deny",
44
+ permissionDecisionReason: reason,
45
+ },
46
+ }),
47
+ );
48
+ }
49
+ } catch {
50
+ // a parse failure must never block a command — silence, not deny
51
+ }
52
+ }
@@ -0,0 +1,383 @@
1
+ // src/adapters/hooks/read-hint.ts — Read hint + re-read hint + commit hint
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Attaches size/read context
4
+ // to file reads. Also includes commitHintFor (OpenCode commit hint).
5
+
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, relative, resolve } from "node:path";
16
+ import { SCAN_EXTS } from "../../analyze.js";
17
+ import { recordHintFire } from "../../core/hint-log.js";
18
+ import { sessionKey } from "../../core/hook-helpers.js";
19
+ import { readMemLog } from "../../memory.js";
20
+ import { renderSeed } from "../../seed/review-seed.js";
21
+ import { readContextData } from "./context-data.js";
22
+
23
+ // --- Read hint (size) ---
24
+
25
+ /** Below this size a full read is already cheap — stay silent. */
26
+ export const READ_HINT_MIN_BYTES = 24_000;
27
+ /** A caller-chosen limit below this is a bounded read — already cheap. */
28
+ export const READ_HINT_MIN_LIMIT = 300;
29
+ /** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
30
+ const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
31
+ /** Cap on the outline attached to the read hint — keeps the hint compact. */
32
+ const READ_HINT_OUTLINE_CAP = 2_000;
33
+
34
+ export interface ReadHintInput {
35
+ filePath: unknown;
36
+ offset?: unknown;
37
+ limit?: unknown;
38
+ cwd: string;
39
+ }
40
+
41
+ /**
42
+ * Factual one-liner for a full-file read of a large source file, or null.
43
+ * Every unknown resolves to null — a hint must never fire on a guess.
44
+ */
45
+ export function readHintFor(opts: ReadHintInput): string | null {
46
+ try {
47
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
48
+ const dot = opts.filePath.lastIndexOf(".");
49
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
50
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
51
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
52
+ const st = statSync(opts.filePath);
53
+ if (!st.isFile() || st.size < READ_HINT_MIN_BYTES) return null;
54
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
55
+ cwd: opts.cwd,
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ });
59
+ if (git.exitCode !== 0) return null;
60
+ const lines = readFileSync(opts.filePath, "utf-8").split("\n").length;
61
+ const rel = relative(opts.cwd, opts.filePath);
62
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
63
+
64
+ let outline = "";
65
+ try {
66
+ const seed = renderSeed(["--files", shown], opts.cwd);
67
+ const sigMatch = seed.match(
68
+ /signatures \(current\):\n([\s\S]*?)(?:\n\w|\nstatic graph)/,
69
+ );
70
+ if (sigMatch) {
71
+ outline = sigMatch[1].trim();
72
+ } else {
73
+ const afterFiles = seed.indexOf("\nimporters");
74
+ if (afterFiles > 0) {
75
+ outline = seed
76
+ .slice(0, Math.min(afterFiles, READ_HINT_OUTLINE_CAP))
77
+ .trim();
78
+ } else {
79
+ outline = seed.slice(0, READ_HINT_OUTLINE_CAP).trim();
80
+ }
81
+ }
82
+ if (outline.length > READ_HINT_OUTLINE_CAP) {
83
+ outline = `${outline.slice(0, READ_HINT_OUTLINE_CAP).trimEnd()}\n… truncated`;
84
+ }
85
+ } catch {
86
+ // review-seed failed — fall back to the command suggestion
87
+ }
88
+
89
+ if (outline) {
90
+ return (
91
+ `fapony: ${shown} is ${lines} lines\n${outline}\n` +
92
+ `(review-seed --files ${shown} for importers + callers; skill /lookup-before-edit)`
93
+ );
94
+ }
95
+ return (
96
+ `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
97
+ `returns exports with line numbers, importers, and signatures first ` +
98
+ `(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
99
+ );
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ // --- Re-read tracking (mtime heuristic) ---
106
+
107
+ const READ_TRACK_DIR = "read-track";
108
+
109
+ export interface ReadTrackRow {
110
+ ts: string;
111
+ path: string;
112
+ mtime: number;
113
+ }
114
+
115
+ /** Directory holding one read log per session. */
116
+ function readTrackDir(): string {
117
+ const base =
118
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
119
+ return join(base, READ_TRACK_DIR);
120
+ }
121
+
122
+ /** Absolute path of a session's read log — may not exist. */
123
+ export function readTrackPath(session: string): string {
124
+ return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
125
+ }
126
+
127
+ function readTrackRows(session: string): ReadTrackRow[] {
128
+ const p = readTrackPath(session);
129
+ if (!existsSync(p)) return [];
130
+ const rows: ReadTrackRow[] = [];
131
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
132
+ if (!line) continue;
133
+ try {
134
+ const r = JSON.parse(line) as ReadTrackRow;
135
+ if (typeof r.path === "string" && typeof r.mtime === "number") {
136
+ rows.push(r);
137
+ }
138
+ } catch {
139
+ // a torn line must not lose the rest of the log
140
+ }
141
+ }
142
+ return rows;
143
+ }
144
+
145
+ function appendReadTrackRow(session: string, row: ReadTrackRow): void {
146
+ const dir = readTrackDir();
147
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
148
+ appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
149
+ }
150
+
151
+ export interface RereadHintInput {
152
+ filePath: unknown;
153
+ offset?: unknown;
154
+ limit?: unknown;
155
+ cwd: string;
156
+ session?: unknown;
157
+ }
158
+
159
+ /**
160
+ * Annotate a full-file read of a path already read this session whose mtime
161
+ * has not moved, or null. Records every full read.
162
+ */
163
+ export function rereadHintFor(opts: RereadHintInput): string | null {
164
+ try {
165
+ if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
166
+ if (typeof opts.session !== "string" || opts.session === "") return null;
167
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
168
+ const offset = typeof opts.offset === "number" ? opts.offset : null;
169
+ if (offset !== null && offset !== 0) return null;
170
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
171
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
172
+
173
+ const abs = (() => {
174
+ const p = opts.filePath.startsWith("/")
175
+ ? opts.filePath
176
+ : join(opts.cwd, opts.filePath);
177
+ try {
178
+ return realpathSync(p);
179
+ } catch {
180
+ return resolve(p);
181
+ }
182
+ })();
183
+ const st = statSync(abs);
184
+ if (!st.isFile()) return null;
185
+ const mtime = Math.round(st.mtimeMs);
186
+
187
+ const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
188
+ const last = prior.at(-1);
189
+ appendReadTrackRow(opts.session, {
190
+ ts: new Date().toISOString(),
191
+ path: abs,
192
+ mtime,
193
+ });
194
+ if (!last || last.mtime !== mtime) return null;
195
+
196
+ const rel = relative(opts.cwd, opts.filePath);
197
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
198
+ return (
199
+ `fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
200
+ `content unchanged since the last read (mtime), grep the line range ` +
201
+ `you need instead of re-reading it`
202
+ );
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ // --- Commit hint (OpenCode tool.execute.after) ---
209
+
210
+ /** Below this number of commits, the hint is unnecessary noise. */
211
+ export const COMMIT_HINT_MIN_COMMITS = 1;
212
+
213
+ export interface CommitHintInput {
214
+ command: unknown;
215
+ cwd: string;
216
+ }
217
+
218
+ function git(args: string[], cwd: string): string | null {
219
+ try {
220
+ const p = Bun.spawnSync(["git", ...args], {
221
+ cwd,
222
+ stdout: "pipe",
223
+ stderr: "pipe",
224
+ });
225
+ return p.exitCode === 0 ? p.stdout.toString().trim() : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ import { hookTsMs, utcStamp } from "../../core/hook-helpers.js";
232
+
233
+ /**
234
+ * Nudge for bash commands containing `git commit` that produced commits with
235
+ * no mem row recorded for them.
236
+ */
237
+ export function commitHintFor(opts: CommitHintInput): string | null {
238
+ try {
239
+ if (typeof opts.command !== "string" || opts.command === "") return null;
240
+ if (!/\bgit\s+commit\b/.test(opts.command)) return null;
241
+
242
+ const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
243
+ if (!worktree) return null;
244
+
245
+ let memLastTs: string | null = null;
246
+ try {
247
+ memLastTs = readMemLog(worktree).rows[0]?.ts ?? null;
248
+ } catch {
249
+ memLastTs = null;
250
+ }
251
+ if (!memLastTs) return null;
252
+ const since = utcStamp(new Date(hookTsMs(memLastTs) + 1000));
253
+
254
+ const log = git(
255
+ ["log", "--since", `${since} +0000`, "--format=%h %s"],
256
+ worktree,
257
+ );
258
+ const commitList = log ? log.split("\n").filter(Boolean) : [];
259
+ if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
260
+
261
+ const lines: string[] = [
262
+ `${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
263
+ ];
264
+ for (const c of commitList.slice(0, 5)) lines.push(` ${c}`);
265
+ if (commitList.length > 5) lines.push(` … +${commitList.length - 5} more`);
266
+ lines.push(
267
+ `fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
268
+ );
269
+
270
+ const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
271
+ return prefixed;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ // --- Read hint CLI command ---
278
+
279
+ /** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out. */
280
+ export async function cmdHookReadHint(): Promise<void> {
281
+ try {
282
+ const raw = JSON.parse(await Bun.stdin.text()) as {
283
+ cwd?: string;
284
+ transcript_path?: string;
285
+ session_id?: string;
286
+ tool_input?: {
287
+ file_path?: unknown;
288
+ offset?: unknown;
289
+ limit?: unknown;
290
+ };
291
+ };
292
+ const cwd = raw.cwd ?? process.cwd();
293
+ const filePath = raw.tool_input?.file_path;
294
+ const session = raw.transcript_path ?? raw.session_id;
295
+ const parts: string[] = [];
296
+ const hint = readHintFor({
297
+ filePath,
298
+ offset: raw.tool_input?.offset,
299
+ limit: raw.tool_input?.limit,
300
+ cwd,
301
+ });
302
+ if (hint) parts.push(hint);
303
+ const reread = rereadHintFor({
304
+ filePath,
305
+ offset: raw.tool_input?.offset,
306
+ limit: raw.tool_input?.limit,
307
+ cwd,
308
+ session,
309
+ });
310
+ if (reread) parts.push(reread);
311
+ const ctx = readContextData(filePath, cwd);
312
+ if (ctx) {
313
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
314
+ parts.push(line);
315
+ }
316
+ }
317
+ if (parts.length > 0) {
318
+ console.log(
319
+ JSON.stringify({
320
+ hookSpecificOutput: {
321
+ hookEventName: "PreToolUse",
322
+ additionalContext: parts.join("\n"),
323
+ },
324
+ }),
325
+ );
326
+ }
327
+
328
+ // --- hint-fire log ---
329
+ const rel =
330
+ typeof filePath === "string"
331
+ ? (() => {
332
+ try {
333
+ const git = Bun.spawnSync(
334
+ ["git", "rev-parse", "--show-toplevel"],
335
+ { cwd, stdout: "pipe", stderr: "pipe" },
336
+ );
337
+ if (git.exitCode !== 0) return null;
338
+ const wt = realpathSync(git.stdout.toString().trim());
339
+ const abs = realpathSync(
340
+ filePath.startsWith("/") ? filePath : join(wt, filePath),
341
+ );
342
+ const r = relative(wt, abs).split("\\").join("/");
343
+ return r.startsWith("..") ? null : r;
344
+ } catch {
345
+ return null;
346
+ }
347
+ })()
348
+ : null;
349
+ const worktree = ctx?.worktree ?? null;
350
+ if (worktree) {
351
+ if (hint) {
352
+ recordHintFire({
353
+ ts: new Date().toISOString(),
354
+ worktree,
355
+ surface: "read",
356
+ file: rel,
357
+ count: 1,
358
+ });
359
+ }
360
+ if (ctx && ctx.debtIds.length > 0) {
361
+ recordHintFire({
362
+ ts: new Date().toISOString(),
363
+ worktree,
364
+ surface: "debt",
365
+ file: rel,
366
+ count: ctx.debtIds.length,
367
+ ids: ctx.debtIds,
368
+ });
369
+ }
370
+ if (ctx && ctx.memLines.length > 0) {
371
+ recordHintFire({
372
+ ts: new Date().toISOString(),
373
+ worktree,
374
+ surface: "mem",
375
+ file: rel,
376
+ count: ctx.memLines.length,
377
+ });
378
+ }
379
+ }
380
+ } catch {
381
+ // any failure = no hint; a hook must never block a read over a hint
382
+ }
383
+ }