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.
Files changed (48) hide show
  1. package/README.md +75 -44
  2. package/fapony.ts +32 -1
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +13 -1
  7. package/src/conventions-seed.ts +0 -1
  8. package/src/db/defaults.ts +1 -1
  9. package/src/db/getters.ts +3 -3
  10. package/src/db/store.ts +0 -75
  11. package/src/db/types.ts +1 -1
  12. package/src/debt.ts +169 -34
  13. package/src/digest/collect.ts +16 -0
  14. package/src/digest/text.ts +25 -0
  15. package/src/hook.ts +424 -16
  16. package/src/init-mem.ts +123 -37
  17. package/src/init.ts +51 -34
  18. package/src/install/claude.ts +7 -5
  19. package/src/install/opencode.ts +59 -5
  20. package/src/lint-baseline.ts +1 -7
  21. package/src/mcp/evidence.ts +1 -1
  22. package/src/mcp/tools/index.ts +97 -185
  23. package/src/mcp/tools/mem.ts +153 -11
  24. package/src/mcp/transport.ts +5 -13
  25. package/src/mem/commands/read.ts +448 -0
  26. package/src/mem/commands/where.ts +56 -0
  27. package/{templates → src}/mem/commands/write.ts +12 -5
  28. package/src/mem/index.ts +144 -0
  29. package/src/mem/store.ts +348 -0
  30. package/src/memory.ts +237 -40
  31. package/src/plan-seed.ts +20 -1
  32. package/src/review-seed.ts +19 -0
  33. package/src/setup.ts +4 -6
  34. package/src/stats/data.ts +34 -86
  35. package/src/stats/format.ts +8 -9
  36. package/src/stats/index.ts +0 -1
  37. package/templates/PLAN.md +1 -0
  38. package/src/mcp/tools/context.ts +0 -66
  39. package/src/mcp/tools/plans.ts +0 -255
  40. package/src/mcp/tools/stats.ts +0 -96
  41. package/templates/mem/commands/read.ts +0 -194
  42. package/templates/mem/commands/selftest.ts +0 -450
  43. package/templates/mem/mem.ts +0 -68
  44. package/templates/mem/store.ts +0 -285
  45. /package/{templates → src}/mem/commands/plan.ts +0 -0
  46. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  47. /package/{templates → src}/mem/render.ts +0 -0
  48. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/init-mem.ts CHANGED
@@ -1,14 +1,19 @@
1
- // src/init-mem.ts — scaffold the canonical .memory/ system into a new worktree.
2
- // Source of truth lives in fapony/templates/mem/ — named after the CLI it implements
3
- // (`mem`), not after where it lands; the destination keeps the .memory name because that
4
- // is where the log lives.
5
- // Destination is <worktree>/.fapony/.memory/ (plans/specs/memory all live under
6
- // .fapony/; run state stays in ~/.config/fapony/state.db, never in the worktree).
7
- // Re-run to re-sync after editing the template — not automatic, on purpose.
1
+ // src/init-mem.ts — cleanup old .memory/ directories and warn about stale call sites.
2
+ //
3
+ // Before PLAN-agent-one-call, this file scaffolded templates/mem/ into every repo.
4
+ // Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
5
+ // This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
8
6
 
9
- import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
- import { dirname, join } from "node:path";
11
- import { loadConfig, memoryEntry } from "./db/index.js";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ rmSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { loadConfig } from "./db/index.js";
12
17
 
13
18
  export function copyDir(src: string, dest: string): string[] {
14
19
  mkdirSync(dest, { recursive: true });
@@ -27,13 +32,11 @@ export function copyDir(src: string, dest: string): string[] {
27
32
  }
28
33
 
29
34
  export function cmdInitMem(args: string[]): void {
30
- const update = args.includes("--update");
35
+ const force = args.includes("--force");
31
36
  const worktreeKey = args.find((x) => !x.startsWith("-"));
32
37
  const config = loadConfig();
33
38
 
34
- // no key given = the repo you are standing in — so `fapony init-mem --update` runs in anyone's project
35
- // without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
36
- // so it picks up that project's paths.memoryEntry by itself)
39
+ // no key given = the repo you are standing in
37
40
  const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
38
41
  if (!worktree) {
39
42
  console.error(`unknown worktree key: ${worktreeKey}`);
@@ -41,31 +44,114 @@ export function cmdInitMem(args: string[]): void {
41
44
  process.exit(1);
42
45
  }
43
46
 
44
- const templateDir = join(import.meta.dir, "..", "templates", "mem");
45
- const memEntry = memoryEntry(config);
46
- const destDir = join(worktree, dirname(memEntry));
47
- const destFile = join(worktree, memEntry);
47
+ const gitRoot = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
48
+ cwd: worktree,
49
+ })
50
+ .stdout.toString()
51
+ .trim();
52
+ const root = gitRoot || worktree;
48
53
 
49
- if (existsSync(destFile) && !update) {
50
- console.error(
51
- `${destFile} already exists — re-running would overwrite local edits.\nRun \`fapony init-mem --update\` to refresh it from the template (log.jsonl is kept).`,
54
+ // 1) Find and delete .memory/ directories (legacy layout)
55
+ const memoryDirs: string[] = [];
56
+ const walk = (dir: string, depth = 0) => {
57
+ if (depth > 4) return;
58
+ if (existsSync(`${dir}/.memory`)) {
59
+ memoryDirs.push(`${dir}/.memory`);
60
+ }
61
+ try {
62
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
+ if (
64
+ entry.isDirectory() &&
65
+ !entry.name.startsWith(".") &&
66
+ entry.name !== "node_modules"
67
+ ) {
68
+ walk(join(dir, entry.name), depth + 1);
69
+ }
70
+ }
71
+ } catch {
72
+ // ignore
73
+ }
74
+ };
75
+ walk(root);
76
+
77
+ if (memoryDirs.length === 0) {
78
+ console.log("no legacy .memory/ directories found — already clean");
79
+ } else {
80
+ let removed = 0;
81
+ let kept = 0;
82
+ for (const d of memoryDirs) {
83
+ // Any log*.jsonl is history — includes log.<person>.jsonl (the standard
84
+ // filename) and rotated log.YYYY-MM-DD.jsonl, not just log.jsonl.
85
+ let logs: string[] = [];
86
+ try {
87
+ logs = readdirSync(d).filter(
88
+ (f) => f === "log.jsonl" || /^log\..*\.jsonl$/.test(f),
89
+ );
90
+ } catch {
91
+ // unreadable dir — fall through and remove
92
+ }
93
+ if (logs.length > 0 && !force) {
94
+ console.log(
95
+ `keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
96
+ );
97
+ console.log(
98
+ ` move them under ${join(d, "..", ".fapony", ".memory")}/, or re-run with --force to delete`,
99
+ );
100
+ kept++;
101
+ continue;
102
+ }
103
+ console.log(
104
+ `deleting ${d}${logs.length > 0 ? ` (--force: ${logs.length} log file(s))` : ""}`,
105
+ );
106
+ rmSync(d, { recursive: true, force: true });
107
+ removed++;
108
+ }
109
+ console.log(
110
+ `\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
111
+ kept > 0
112
+ ? ` · kept ${kept} with logs — move them under .fapony/.memory/, then re-run`
113
+ : ""
114
+ }`,
52
115
  );
53
- process.exit(1);
54
- }
55
- if (update && !existsSync(destFile)) {
56
- console.error(`${destFile} not found — run \`fapony init\` first.`);
57
- process.exit(1);
58
116
  }
59
117
 
60
- const files = copyDir(templateDir, destDir);
61
- if (update) {
62
- console.log(`updated ${files.length} files in ${destDir}`);
63
- console.log(`(log.jsonl and other data files left untouched)`);
64
- return;
65
- }
66
- console.log(`scaffolded ${files.length} files into ${destDir}`);
67
- console.log(`\nAdd to fapony.config.json:`);
68
- console.log(
69
- ` "memory": {\n "claim": ["bun", "${memEntry}", "claim", "{id}"],\n "close": ["bun", "${memEntry}", "close", "{id}", "{msg}"],\n "add": ["bun", "${memEntry}", "add", "{kind}", "{text}"],\n "kickoff": ["bun", "${memEntry}", "kickoff"]\n }`,
70
- );
118
+ // 2) Warn about package.json call sites still referencing .memory/mem.ts
119
+ const warnAboutCallSites = (dir: string, depth = 0) => {
120
+ if (depth > 4) return;
121
+ const pkg = join(dir, "package.json");
122
+ if (existsSync(pkg)) {
123
+ try {
124
+ const raw = readFileSync(pkg, "utf8");
125
+ if (raw.includes(".memory/mem.ts")) {
126
+ console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
127
+ console.log(
128
+ ` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
129
+ );
130
+ }
131
+ } catch {
132
+ // ignore
133
+ }
134
+ }
135
+ try {
136
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
137
+ if (
138
+ entry.isDirectory() &&
139
+ !entry.name.startsWith(".") &&
140
+ entry.name !== "node_modules"
141
+ ) {
142
+ warnAboutCallSites(join(dir, entry.name), depth + 1);
143
+ }
144
+ }
145
+ } catch {
146
+ // ignore
147
+ }
148
+ };
149
+ warnAboutCallSites(root);
150
+
151
+ console.log("\nmem commands are now built into fapony:");
152
+ console.log(' fapony mem add <kind> "<text>" --files <files>');
153
+ console.log(' fapony mem close <id> "<text>"');
154
+ console.log(" fapony mem find <word>");
155
+ console.log(" fapony mem kickoff [id|spec.md]");
156
+ console.log(" fapony mem now");
71
157
  }
package/src/init.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  // state.db stays in ~/.config/fapony/ by design (security boundary — see db.ts),
4
4
  // never inside the worktree where agents have full write access.
5
5
 
6
- import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ } from "node:fs";
7
13
  import { dirname, join, relative } from "node:path";
8
14
  import { createInterface } from "node:readline";
9
15
  import { seedConventionsFile } from "./conventions-seed.js";
@@ -11,11 +17,10 @@ import {
11
17
  type Config,
12
18
  doneDir,
13
19
  evidenceFile,
14
- memoryEntry,
20
+ memoryDir,
15
21
  planDir,
16
22
  specDir,
17
23
  } from "./db/index.js";
18
- import { copyDir } from "./init-mem.js";
19
24
  import { isAffirmative } from "./util.js";
20
25
 
21
26
  const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
@@ -37,7 +42,7 @@ const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
37
42
  # running in this worktree cannot rewrite run state / audit trail.
38
43
  #
39
44
  # Ask your agent for the plan picture instead of listing these by hand:
40
- # "run plan_list" — what is active, blocked, untouched, archived
45
+ # run "fapony mem kickoff" — priority plans, the first unchecked chunk, open bugs
41
46
  `;
42
47
 
43
48
  // Static template — deliberately NOT derived from the repo (reading package.json
@@ -59,22 +64,22 @@ const EVIDENCE_JSON = `{
59
64
  // Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
60
65
  // and most of them never ran `fapony init` — it would tell them to run a command
61
66
  // that does not exist.
62
- const RULES_SNIPPET = (
63
- memEntry: string,
64
- ) => `## Memory: ${dirname(memEntry)}/log.<you>.jsonl (append-only)
67
+ const RULES_SNIPPET =
68
+ () => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
65
69
 
66
70
  The log is this project's shared brain — it lives in git, so anyone who clones the
67
71
  repo gets every decision, bug and note with it. The filename comes from
68
- \`git config user.name\`, one file per person, so there is nothing to merge.
72
+ \`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
73
+ .gitattributes keeps both sides when two people end up sharing a name anyway.
69
74
 
70
75
  Log as you work — do not wait to be asked. Nothing writes it for you:
71
76
 
72
- bun ${memEntry} kickoff <plan.md> # start a session with this
73
- bun ${memEntry} add decision "what was locked, and why" --files src/x.ts
74
- bun ${memEntry} add bug "what is broken" --files src/x.ts
75
- bun ${memEntry} add note "state the next session needs" --files src/x.ts
76
- bun ${memEntry} close <id> "fixed in <sha>"
77
- bun ${memEntry} find "<text>"
77
+ fapony mem kickoff <plan.md> # start a session with this
78
+ fapony mem add decision "what was locked, and why" --files src/x.ts
79
+ fapony mem add bug "what is broken" --files src/x.ts
80
+ fapony mem add note "state the next session needs" --files src/x.ts
81
+ fapony mem close <id> "fixed in <sha>"
82
+ fapony mem find "<text>"
78
83
 
79
84
  Write each entry standalone — it is read months later with no chat to refer to.
80
85
  --files is required: rows that name no file cannot be recalled when that file is
@@ -90,13 +95,13 @@ Finish a chunk, before starting the next:
90
95
  1. Tick its checkbox + stamp the TL;DR in the plan file
91
96
  2. Commit — separate from other chunks
92
97
  3. \`verdict_submit\` (fapony MCP), grading what actually happened
93
- 4. \`bun ${memEntry} add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
94
- — pass the exact same plan path every time; kickoff matches it as a literal string
98
+ 4. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
99
+ — use the same plan path every time
95
100
  5. Stop. Do not continue to the next chunk in the same session unless told to.
96
101
 
97
- Next chunk, new session — open with \`bun ${memEntry} kickoff <path/to/PLAN-x.md>\` (same
98
- path) instead of carrying the old transcript forward. kickoff already filters to the
99
- rows written against that exact path.`;
102
+ Next chunk, new session — open with \`fapony mem kickoff <path/to/PLAN-x.md>\` instead
103
+ of carrying the old transcript forward. kickoff already filters to the rows for that plan,
104
+ and takes just the filename (\`kickoff PLAN-x.md\`) when you do not want to type the path.`;
100
105
 
101
106
  export function initProject(targetPath: string, config?: Config): void {
102
107
  // Create target root
@@ -112,6 +117,22 @@ export function initProject(targetPath: string, config?: Config): void {
112
117
  mkdirSync(faponyDir, { recursive: true });
113
118
  writeFileSync(join(faponyDir, "README"), FAPONY_README);
114
119
 
120
+ // --- .gitattributes: union-merge the append-only logs ---
121
+ //
122
+ // Every row is one person's append, so both sides of a "conflict" are always
123
+ // right. Without the line, two people whose git user.name collides (admin /
124
+ // user / owner — what a fresh OS install offers) resolve a conflict by hand
125
+ // on every pull. Appended, never rewritten: the file is the repo's, not ours.
126
+ const attrs = join(targetPath, ".gitattributes");
127
+ const attrsBody = existsSync(attrs) ? readFileSync(attrs, "utf-8") : "";
128
+ if (!/^\s*\*\.jsonl\s+merge=union\s*$/m.test(attrsBody)) {
129
+ appendFileSync(
130
+ attrs,
131
+ `${attrsBody && !attrsBody.endsWith("\n") ? "\n" : ""}# append-only memory logs: keep both sides, never hand-resolve\n*.jsonl merge=union\n`,
132
+ );
133
+ console.log(` + ${attrs} — *.jsonl merge=union`);
134
+ }
135
+
115
136
  // --- evidence.json (verification_report allowlist — see src/mcp/evidence.ts) ---
116
137
  const evidencePath = join(targetPath, evidenceFile(config));
117
138
  if (existsSync(evidencePath)) {
@@ -141,19 +162,16 @@ export function initProject(targetPath: string, config?: Config): void {
141
162
  }
142
163
  mkdirSync(specDirAbs, { recursive: true });
143
164
 
144
- // --- .memory/ (from template) ---
145
- const memEntry = memoryEntry(config); // e.g. .fapony/.memory/mem.ts
146
- const memoryDir = join(
147
- targetPath,
148
- memEntry.split("/").slice(0, -1).join("/"),
149
- );
150
- if (existsSync(join(targetPath, memEntry))) {
151
- throw new Error(
152
- `${join(targetPath, memEntry)} already exists — delete it first if you want a fresh copy.`,
165
+ // --- .fapony/.memory/ (empty dir — mem commands are built into fapony now) ---
166
+ const memoryDirPath = join(targetPath, memoryDir(config));
167
+ if (existsSync(memoryDirPath)) {
168
+ console.log(` ${relative(targetPath, memoryDirPath)}/ — already exists`);
169
+ } else {
170
+ mkdirSync(memoryDirPath, { recursive: true });
171
+ console.log(
172
+ ` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
153
173
  );
154
174
  }
155
- const templateDir = join(import.meta.dir, "..", "templates", "mem");
156
- const files = copyDir(templateDir, memoryDir);
157
175
 
158
176
  console.log(`scaffolded ${targetPath}/`);
159
177
  console.log(
@@ -166,13 +184,13 @@ export function initProject(targetPath: string, config?: Config): void {
166
184
  ` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
167
185
  );
168
186
  console.log(
169
- ` ${relative(targetPath, memoryDir)}/ — ${files.length} files from template`,
187
+ ` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
170
188
  );
171
189
  console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
172
190
  console.log(
173
191
  `\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
174
192
  );
175
- console.log(RULES_SNIPPET(memEntry));
193
+ console.log(RULES_SNIPPET());
176
194
  }
177
195
 
178
196
  const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
@@ -228,8 +246,7 @@ export async function cmdInit(args: string[]): Promise<void> {
228
246
  );
229
247
  if (!isAffirmative(answer)) return;
230
248
 
231
- const memEntry = memoryEntry();
232
- const snippet = `\n\n${RULES_SNIPPET(memEntry)}\n`;
249
+ const snippet = `\n\n${RULES_SNIPPET()}\n`;
233
250
  for (const f of found) {
234
251
  appendFileSync(f, snippet);
235
252
  console.log(` appended to ${relative(targetPath, f)}`);
@@ -345,11 +345,13 @@ function installStopHook(dryRun: boolean, deps: InstallDeps): void {
345
345
  }
346
346
 
347
347
  /**
348
- * PreToolUse hook on Read: annotates a full-file read of a large source file
349
- * with one factual line (size + the review-seed command). Annotate only —
350
- * no permissionDecision is ever returned, the read always proceeds; and no
351
- * "already read" dedupe (context compaction makes that claim false). The
352
- * matcher "Read" keeps the spawn off every other tool call.
348
+ * PreToolUse hook on Read: annotates a full-file read with one factual line —
349
+ * the size + the review-seed command when the file is large, and the re-read
350
+ * line when the same path was already read this session and its mtime has not
351
+ * moved (an unchanged file, so the second read buys nothing; a changed one
352
+ * stays silent). Annotate only — no permissionDecision is ever returned, the
353
+ * read always proceeds. The matcher "Read" keeps the spawn off every other
354
+ * tool call.
353
355
  */
354
356
  function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
355
357
  ensureClaudeHook(dryRun, deps, {
@@ -185,27 +185,64 @@ export function readHintPluginSource(root: string): string {
185
185
  // factual review-seed pointer, plus any convention debt or mem-log row about
186
186
  // the file. Annotate only: never blocks, never dedupes.
187
187
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
188
- import { readHintFor, readContextLines } from ${hookModule};
188
+ import { readHintFor, rereadHintFor, readContextData, recordHintFire } from ${hookModule};
189
189
 
190
190
  export const FaponyReadHint = async ({ directory }) => {
191
191
  return {
192
192
  "tool.execute.after": async (input, output) => {
193
193
  try {
194
194
  if (input.tool !== "read") return;
195
+ const filePath = input.args?.filePath;
195
196
  const parts = [];
196
197
  const hint = readHintFor({
197
- filePath: input.args?.filePath,
198
+ filePath,
198
199
  offset: input.args?.offset,
199
200
  limit: input.args?.limit,
200
201
  cwd: directory,
201
202
  });
202
203
  if (hint) parts.push(hint);
203
- for (const line of readContextLines(input.args?.filePath, directory)) {
204
- parts.push(line);
204
+ const reread = rereadHintFor({
205
+ filePath,
206
+ offset: input.args?.offset,
207
+ limit: input.args?.limit,
208
+ cwd: directory,
209
+ session: input.sessionID,
210
+ });
211
+ if (reread) parts.push(reread);
212
+ const ctx = readContextData(filePath, directory);
213
+ if (ctx) {
214
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
215
+ parts.push(line);
216
+ }
205
217
  }
206
218
  if (parts.length > 0 && typeof output.output === "string") {
207
219
  output.output = output.output + "\\n" + parts.join("\\n");
208
220
  }
221
+
222
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
223
+ // After output — best-effort, never block the hint.
224
+ if (ctx?.worktree) {
225
+ const rel = typeof filePath === "string" ? (() => {
226
+ try {
227
+ const { realpathSync } = require("node:fs");
228
+ const { relative } = require("node:path");
229
+ const { join: pjoin } = require("node:path");
230
+ const wt = ctx.worktree;
231
+ const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(wt, filePath));
232
+ const r = relative(wt, abs).split("\\\\").join("/");
233
+ return r.startsWith("..") ? null : r;
234
+ } catch { return null; }
235
+ })() : null;
236
+ if (hint) {
237
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "read", file: rel, count: 1 });
238
+ }
239
+ if (ctx.debtIds.length > 0) {
240
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
241
+ }
242
+ if (ctx.memLines.length > 0) {
243
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
244
+ }
245
+ }
209
246
  } catch {
210
247
  // a hint must never break a read
211
248
  }
@@ -230,7 +267,7 @@ export function commitHintPluginSource(root: string): string {
230
267
  // verdict reminder when commits have no verdict filed yet.
231
268
  // Annotate only: never blocks, never dedupes.
232
269
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
233
- import { commitHintFor } from ${hookModule};
270
+ import { commitHintFor, recordHintFire } from ${hookModule};
234
271
 
235
272
  export const FaponyCommitHint = async ({ directory }) => {
236
273
  return {
@@ -245,6 +282,23 @@ export const FaponyCommitHint = async ({ directory }) => {
245
282
  if (hint && typeof output.output === "string") {
246
283
  output.output = output.output + "\\n" + hint;
247
284
  }
285
+
286
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
287
+ // After output — best-effort, never block the hint.
288
+ if (hint) {
289
+ try {
290
+ const { spawnSync } = require("node:child_process");
291
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
292
+ if (git.status === 0) {
293
+ const worktree = require("node:fs").realpathSync(git.stdout.trim());
294
+ // Count commits from the hint message (lines starting with "fapony: ").
295
+ const commitLines = hint.split("\\n").filter(l => /^fapony:\\s+\\w{7,}/.test(l));
296
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "commit", file: null, count: commitLines.length || 1 });
297
+ }
298
+ } catch {
299
+ // best-effort
300
+ }
301
+ }
248
302
  } catch {
249
303
  // a hint must never break a bash command
250
304
  }
@@ -12,13 +12,7 @@
12
12
  // never blocks — the agent decides whether to fix the old issues too
13
13
 
14
14
  import { execSync } from "node:child_process";
15
- import {
16
- existsSync,
17
- mkdirSync,
18
- readFileSync,
19
- rmSync,
20
- writeFileSync,
21
- } from "node:fs";
15
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
16
  import { join, relative } from "node:path";
23
17
  import { faponyDir } from "./db/load.js";
24
18
  import { assertSafe } from "./safety.js";
@@ -41,7 +41,7 @@ const AGENT_PROVENANCE = { verified: false, source: "agent_report" } as const;
41
41
 
42
42
  // --- App-scoped resolution ---
43
43
 
44
- // Monorepo group dirs — same order as templates/mem/store.ts (first hit wins there;
44
+ // Monorepo group dirs — same order as src/mem/store.ts (first hit wins there;
45
45
  // here every file must already agree on one group+app, so order never matters).
46
46
  const APP_GROUPS = ["apps", "packages", "services"];
47
47