fapony 0.1.3 → 0.2.1

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 (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  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 +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
@@ -113,6 +113,7 @@ export function cmdInstallOpencode(
113
113
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
114
114
  installReadHintPlugin(dryRun, getHome);
115
115
  installCommitHintPlugin(dryRun, getHome);
116
+ installEditHintPlugin(dryRun, getHome);
116
117
  return;
117
118
  }
118
119
 
@@ -136,6 +137,7 @@ export function cmdInstallOpencode(
136
137
  console.log(computeDiff(before, after));
137
138
  installReadHintPlugin(dryRun, getHome);
138
139
  installCommitHintPlugin(dryRun, getHome);
140
+ installEditHintPlugin(dryRun, getHome);
139
141
  return;
140
142
  }
141
143
 
@@ -157,6 +159,7 @@ export function cmdInstallOpencode(
157
159
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
158
160
  installReadHintPlugin(dryRun, getHome);
159
161
  installCommitHintPlugin(dryRun, getHome);
162
+ installEditHintPlugin(dryRun, getHome);
160
163
  }
161
164
 
162
165
  /**
@@ -185,27 +188,64 @@ export function readHintPluginSource(root: string): string {
185
188
  // factual review-seed pointer, plus any convention debt or mem-log row about
186
189
  // the file. Annotate only: never blocks, never dedupes.
187
190
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
188
- import { readHintFor, readContextLines } from ${hookModule};
191
+ import { readHintFor, rereadHintFor, readContextData, recordHintFire } from ${hookModule};
189
192
 
190
193
  export const FaponyReadHint = async ({ directory }) => {
191
194
  return {
192
195
  "tool.execute.after": async (input, output) => {
193
196
  try {
194
197
  if (input.tool !== "read") return;
198
+ const filePath = input.args?.filePath;
195
199
  const parts = [];
196
200
  const hint = readHintFor({
197
- filePath: input.args?.filePath,
201
+ filePath,
198
202
  offset: input.args?.offset,
199
203
  limit: input.args?.limit,
200
204
  cwd: directory,
201
205
  });
202
206
  if (hint) parts.push(hint);
203
- for (const line of readContextLines(input.args?.filePath, directory)) {
204
- parts.push(line);
207
+ const reread = rereadHintFor({
208
+ filePath,
209
+ offset: input.args?.offset,
210
+ limit: input.args?.limit,
211
+ cwd: directory,
212
+ session: input.sessionID,
213
+ });
214
+ if (reread) parts.push(reread);
215
+ const ctx = readContextData(filePath, directory);
216
+ if (ctx) {
217
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
218
+ parts.push(line);
219
+ }
205
220
  }
206
221
  if (parts.length > 0 && typeof output.output === "string") {
207
222
  output.output = output.output + "\\n" + parts.join("\\n");
208
223
  }
224
+
225
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
226
+ // After output — best-effort, never block the hint.
227
+ if (ctx?.worktree) {
228
+ const rel = typeof filePath === "string" ? (() => {
229
+ try {
230
+ const { realpathSync } = require("node:fs");
231
+ const { relative } = require("node:path");
232
+ const { join: pjoin } = require("node:path");
233
+ const wt = ctx.worktree;
234
+ const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
235
+ const r = relative(wt, abs).split("\\\\").join("/");
236
+ return r.startsWith("..") ? null : r;
237
+ } catch { return null; }
238
+ })() : null;
239
+ if (hint) {
240
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "read", file: rel, count: 1 });
241
+ }
242
+ if (ctx.debtIds.length > 0) {
243
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
244
+ }
245
+ if (ctx.memLines.length > 0) {
246
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
247
+ }
248
+ }
209
249
  } catch {
210
250
  // a hint must never break a read
211
251
  }
@@ -230,7 +270,7 @@ export function commitHintPluginSource(root: string): string {
230
270
  // verdict reminder when commits have no verdict filed yet.
231
271
  // Annotate only: never blocks, never dedupes.
232
272
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
233
- import { commitHintFor } from ${hookModule};
273
+ import { commitHintFor, recordHintFire } from ${hookModule};
234
274
 
235
275
  export const FaponyCommitHint = async ({ directory }) => {
236
276
  return {
@@ -245,6 +285,23 @@ export const FaponyCommitHint = async ({ directory }) => {
245
285
  if (hint && typeof output.output === "string") {
246
286
  output.output = output.output + "\\n" + hint;
247
287
  }
288
+
289
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
290
+ // After output — best-effort, never block the hint.
291
+ if (hint) {
292
+ try {
293
+ const { spawnSync } = require("node:child_process");
294
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
295
+ if (git.status === 0) {
296
+ const worktree = require("node:fs").realpathSync(git.stdout.trim());
297
+ // Count commits from the hint message (lines starting with "fapony: ").
298
+ const commitLines = hint.split("\\n").filter(l => /^fapony:\\s+\\w{7,}/.test(l));
299
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "commit", file: null, count: commitLines.length || 1 });
300
+ }
301
+ } catch {
302
+ // best-effort
303
+ }
304
+ }
248
305
  } catch {
249
306
  // a hint must never break a bash command
250
307
  }
@@ -254,6 +311,80 @@ export const FaponyCommitHint = async ({ directory }) => {
254
311
  `;
255
312
  }
256
313
 
314
+ /**
315
+ * OpenCode plugin — the edit hint's in-process shape. `tool.execute.after`
316
+ * on the edit and write tools: when the touched file has importers, appends
317
+ * the importer count plus the review-seed command that lists them. Annotate
318
+ * only (no block, no dedupe beyond what editHintFor does itself).
319
+ *
320
+ * This is the OpenCode parity of Claude's Edit PreToolUse hook
321
+ * (PLAN-edit-importer-hint): same shared logic (editHintFor in src/hook.ts),
322
+ * same once-per-(session, file) dedupe, same `"edit"` fire surface. One
323
+ * known difference: OpenCode's only annotate channel is `after`, so the hint
324
+ * lands one step after the edit instead of before it — the `before` hook can
325
+ * only mutate args or throw, and a hint must never block a tool call.
326
+ *
327
+ * `write` is guarded alongside `edit` for free: a write to a new file
328
+ * resolves to null inside editHintFor (nothing imports it yet), so only a
329
+ * write that overwrites an imported file fires. `apply_patch` is out of
330
+ * scope — its paths live inside patchText, not filePath (separate plan).
331
+ */
332
+ export function editHintPluginSource(root: string): string {
333
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
334
+ return `// fapony edit hint — annotates edits to source files that have importers
335
+ // with the importer count plus the review-seed command that lists them.
336
+ // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
337
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
338
+ import { editHintFor, recordHintFire } from ${hookModule};
339
+
340
+ export const FaponyEditHint = async ({ directory }) => {
341
+ return {
342
+ "tool.execute.after": async (input, output) => {
343
+ try {
344
+ if (input.tool !== "edit" && input.tool !== "write") return;
345
+ // The only channel that reaches the agent is output.output; if it is
346
+ // not a string the hint cannot surface, so bail before editHintFor
347
+ // spends the per-session dedupe row or logs a fire that never showed.
348
+ if (typeof output.output !== "string") return;
349
+ const filePath = input.args?.filePath;
350
+ const hint = editHintFor({
351
+ filePath,
352
+ cwd: directory,
353
+ session: input.sessionID,
354
+ });
355
+
356
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
357
+ // After output — best-effort, never block the hint.
358
+ if (hint) {
359
+ output.output = output.output + "\\n" + hint;
360
+ try {
361
+ const { spawnSync } = require("node:child_process");
362
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
363
+ if (git.status === 0) {
364
+ const worktree = require("node:fs").realpathSync(git.stdout.trim());
365
+ const rel = typeof filePath === "string" ? (() => {
366
+ try {
367
+ const { relative, join: pjoin } = require("node:path");
368
+ const abs = require("node:fs").realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
369
+ const r = relative(worktree, abs).split("\\\\").join("/");
370
+ return r.startsWith("..") ? null : r;
371
+ } catch { return null; }
372
+ })() : null;
373
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
374
+ }
375
+ } catch {
376
+ // best-effort
377
+ }
378
+ }
379
+ } catch {
380
+ // a hint must never break an edit
381
+ }
382
+ },
383
+ };
384
+ };
385
+ `;
386
+ }
387
+
257
388
  function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
258
389
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
259
390
  const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
@@ -319,3 +450,36 @@ function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
319
450
  ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
320
451
  );
321
452
  }
453
+
454
+ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
455
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
456
+ const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
457
+ if (existsSync(pluginPath)) {
458
+ let current = "";
459
+ try {
460
+ current = readFileSync(pluginPath, "utf-8");
461
+ } catch {
462
+ current = "";
463
+ }
464
+ if (current.includes("editHintFor")) {
465
+ console.error(` edit hint: already installed — no change`);
466
+ return;
467
+ }
468
+ console.error(
469
+ ` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
470
+ );
471
+ return;
472
+ }
473
+ if (!dryRun) {
474
+ try {
475
+ mkdirSync(pluginsDir, { recursive: true });
476
+ writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
477
+ } catch (e) {
478
+ console.error(` edit hint: failed to write — ${(e as Error).message}`);
479
+ return;
480
+ }
481
+ }
482
+ console.error(
483
+ ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
484
+ );
485
+ }
package/src/install.ts CHANGED
@@ -28,12 +28,13 @@ export {
28
28
  claudeGetPointsToFapony,
29
29
  cmdInstallClaude,
30
30
  } from "./install/claude.js";
31
- export { cmdInstallCodex } from "./install/codex.js";
31
+ export { cmdInstallCodex, findCodexHooksJson } from "./install/codex.js";
32
32
  export { cmdInstallCursor } from "./install/cursor.js";
33
33
  export { detectClients } from "./install/detect.js";
34
34
  export {
35
35
  cmdInstallOpencode,
36
36
  commitHintPluginSource,
37
+ editHintPluginSource,
37
38
  readHintPluginSource,
38
39
  } from "./install/opencode.js";
39
40
  export {
@@ -12,14 +12,9 @@
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";
17
+ import { DEFAULT_EVIDENCE_FILE } from "./db/index.js";
23
18
  import { faponyDir } from "./db/load.js";
24
19
  import { assertSafe } from "./safety.js";
25
20
 
@@ -63,7 +58,7 @@ function baselinePath(worktree: string): string {
63
58
  }
64
59
 
65
60
  function readEvidenceLintCmd(worktree: string): string | null {
66
- const evidence = join(worktree, ".fapony", "evidence.json");
61
+ const evidence = join(worktree, DEFAULT_EVIDENCE_FILE);
67
62
  try {
68
63
  const parsed = JSON.parse(readFileSync(evidence, "utf-8")) as {
69
64
  commands?: { name?: string; cmd?: string }[];
@@ -17,7 +17,13 @@
17
17
  import { execSync } from "node:child_process";
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { join, relative } from "node:path";
20
- import { type Config, evidenceFile, safetyDeny } from "../db/index.js";
20
+ import {
21
+ type Config,
22
+ EVIDENCE_FILENAME,
23
+ evidenceFile,
24
+ FAPONY_DIR,
25
+ safetyDeny,
26
+ } from "../db/index.js";
21
27
  import { assertSafe } from "../safety.js";
22
28
  import type { EvidenceItem, EvidenceStatus } from "./primitives.js";
23
29
 
@@ -41,7 +47,7 @@ const AGENT_PROVENANCE = { verified: false, source: "agent_report" } as const;
41
47
 
42
48
  // --- App-scoped resolution ---
43
49
 
44
- // Monorepo group dirs — same order as templates/mem/store.ts (first hit wins there;
50
+ // Monorepo group dirs — same order as src/mem/store.ts (first hit wins there;
45
51
  // here every file must already agree on one group+app, so order never matters).
46
52
  const APP_GROUPS = ["apps", "packages", "services"];
47
53
 
@@ -80,7 +86,13 @@ export function resolveEvidencePath(
80
86
  APP_GROUPS.includes(group) &&
81
87
  heads.every(([g, a]) => g === group && a === app)
82
88
  ) {
83
- const candidate = join(worktree, group, app, ".fapony", "evidence.json");
89
+ const candidate = join(
90
+ worktree,
91
+ group,
92
+ app,
93
+ FAPONY_DIR,
94
+ EVIDENCE_FILENAME,
95
+ );
84
96
  if (existsSync(candidate)) return candidate;
85
97
  }
86
98
  }