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/debt.ts CHANGED
@@ -18,18 +18,16 @@
18
18
  //
19
19
  // Read-only stdout: no file writes, no state.db, no cache (rule 5b).
20
20
 
21
- import { existsSync, readFileSync, statSync } from "node:fs";
22
- import { isAbsolute, join, relative, resolve } from "node:path";
21
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
22
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
23
23
  import { collectSourceFiles } from "./analyze.js";
24
24
  import { openDb } from "./db/index.js";
25
- import { type MemRow, readMemLog, resolveAppFaponyDir } from "./memory.js";
25
+ import { readMemLog, resolveMemDir } from "./memory.js";
26
26
 
27
27
  // A stale regex matching more than this many files is not a convention — it is
28
28
  // a broken/wide regex (stale="e" would flag the repo). SPEC §6: drop the entry
29
29
  // and say so, never report 600 files.
30
30
  const DEBT_FILE_CAP = 250;
31
- // Same cap for the file list printed per convention — a wall is not an answer.
32
- const LIST_SHOWN = 40;
33
31
 
34
32
  export interface Convention {
35
33
  id: string;
@@ -51,7 +49,10 @@ export interface Convention {
51
49
  // --- conventions.json resolution (same guess as the mem log, SPEC §2.1) ---
52
50
 
53
51
  export function resolveConventionsPath(worktree: string): string | null {
54
- const base = resolveAppFaponyDir(worktree);
52
+ // Conventions live in the same .fapony/ dir as the mem log — derive from
53
+ // the resolved mem dir so both resolvers cannot drift apart.
54
+ const memDir = resolveMemDir(worktree);
55
+ const base = memDir ? join(memDir, "..") : join(worktree, ".fapony");
55
56
  const app = join(base, "conventions.json");
56
57
  if (existsSync(app)) return app;
57
58
  // Monorepo where the app has not scaffolded .fapony/ yet, and single repos
@@ -295,7 +296,7 @@ export function debtScan(
295
296
  // Stop counting a runaway regex early — the entry will be dropped.
296
297
  if (cur.length > DEBT_FILE_CAP) tooBroad.set(c.conv.id, cur.length);
297
298
  }
298
- if (c.okRe && c.okRe.test(content)) {
299
+ if (c.okRe?.test(content)) {
299
300
  moved.set(c.conv.id, (moved.get(c.conv.id) ?? 0) + 1);
300
301
  }
301
302
  }
@@ -509,41 +510,99 @@ export function formatPromotions(promotions: Promotion[]): string[] {
509
510
 
510
511
  // --- Formatting ---
511
512
 
512
- const WRAP_WIDTH = 88;
513
+ // Zone grouping: a zone is a file's *directory*, capped at this many leading
514
+ // segments — never a fixed-depth prefix of the path (which would cut into the
515
+ // filename) and never the filename itself. SPEC §4 shows zones at depth 5
516
+ // (`apps/mdl/src/server/services`) and depth 3 (`packages/cache/src`) in the
517
+ // same report, so the cap must follow the directory, not a constant.
518
+ const ZONE_DEPTH = 5;
519
+ // Default cap on zones shown per convention — more than this is a wall, not an answer.
520
+ const ZONE_CAP = 6;
521
+
522
+ /** The zone of a file: its directory path, capped at `depth` segments. */
523
+ function zoneOf(file: string, depth: number): string {
524
+ const parts = dirname(file)
525
+ .split("/")
526
+ .filter((p) => p && p !== ".");
527
+ return parts.slice(0, depth).join("/") || ".";
528
+ }
513
529
 
514
- function wrapFiles(files: string[]): string[] {
515
- const lines: string[] = [];
516
- let cur = "";
530
+ /** Group files by their directory zone (see `zoneOf`). */
531
+ function groupFilesByZone(
532
+ files: string[],
533
+ depth: number,
534
+ ): Map<string, string[]> {
535
+ const zones = new Map<string, string[]>();
517
536
  for (const f of files) {
518
- const piece = cur ? `${cur} · ${f}` : f;
519
- if (piece.length > WRAP_WIDTH && cur) {
520
- lines.push(` ${cur}`);
521
- cur = f;
522
- } else {
523
- cur = piece;
524
- }
537
+ const zone = zoneOf(f, depth);
538
+ const cur = zones.get(zone) ?? [];
539
+ cur.push(f);
540
+ zones.set(zone, cur);
525
541
  }
526
- if (cur) lines.push(` ${cur}`);
527
- return lines;
542
+ // Sort zones by file count descending, then alphabetically
543
+ return new Map(
544
+ [...zones.entries()].sort((a, b) => {
545
+ const d = b[1].length - a[1].length;
546
+ return d !== 0 ? d : a[0].localeCompare(b[0]);
547
+ }),
548
+ );
549
+ }
550
+
551
+ /** Escape a regex source for use in a shell grep command. */
552
+ function shellEscapeRe(src: string): string {
553
+ return src.replace(/'/g, "'\\''");
528
554
  }
529
555
 
530
- export function formatDebt(report: DebtReport): string {
556
+ export function formatDebt(report: DebtReport, showAll = false): string {
531
557
  const lines: string[] = [];
532
558
  lines.push(
533
559
  `fapony debt — ${report.entries.length + report.declared.length + report.checkedCount} convention(s), ` +
534
560
  `${report.scannedFiles} files scanned, ${report.ms}ms — derived fresh, not stored`,
535
561
  );
536
562
  for (const e of report.entries) {
537
- const moved = e.movedCount !== null ? ` · moved ${e.movedCount}` : "";
538
- lines.push(`\n${e.conv.id} — ${e.conv.rule} (where ${e.conv.where})`);
563
+ const moved =
564
+ e.movedCount !== null && e.files.length > 0
565
+ ? ` · moved ${e.movedCount} (${Math.round((e.movedCount / (e.files.length + e.movedCount)) * 100)}%)`
566
+ : e.movedCount !== null
567
+ ? ` · moved ${e.movedCount}`
568
+ : "";
569
+ lines.push(`\n${e.conv.id} — ${e.conv.rule}`);
570
+ // Show the patterns actually used
571
+ const patterns: string[] = [];
572
+ if (e.conv.stale) patterns.push(`stale: ${e.conv.stale}`);
573
+ if (e.conv.ok) patterns.push(`ok: ${e.conv.ok}`);
574
+ if (e.conv.guard) patterns.push(`guard: ${e.conv.guard}`);
575
+ patterns.push(`where ${e.conv.where}`);
576
+ lines.push(` ${patterns.join(" · ")}`);
539
577
  if (e.files.length === 0) {
540
578
  lines.push(` debt 0${moved} — clean`);
541
579
  continue;
542
580
  }
543
- lines.push(` debt ${e.files.length}${moved}:`);
544
- lines.push(...wrapFiles(e.files.slice(0, LIST_SHOWN)));
545
- if (e.files.length > LIST_SHOWN) {
546
- lines.push(` … +${e.files.length - LIST_SHOWN} more files`);
581
+ lines.push(` debt ${e.files.length}${moved}`);
582
+ // Verify command derived from stale
583
+ if (e.conv.stale) {
584
+ lines.push(` verify: grep -rn '${shellEscapeRe(e.conv.stale)}' <zone>`);
585
+ }
586
+ // Zone grouping
587
+ const zones = groupFilesByZone(e.files, ZONE_DEPTH);
588
+ const zoneEntries = [...zones.entries()];
589
+ const cap = showAll
590
+ ? zoneEntries.length
591
+ : Math.min(zoneEntries.length, ZONE_CAP);
592
+ let totalCapped = 0;
593
+ for (let i = 0; i < cap; i++) {
594
+ const [zone, zoneFiles] = zoneEntries[i];
595
+ const pad = " ".repeat(Math.max(0, 42 - zone.length));
596
+ lines.push(`\n ${zone}${pad}${zoneFiles.length} ไฟล์`);
597
+ lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
598
+ totalCapped += zoneFiles.length;
599
+ }
600
+ if (zoneEntries.length > cap) {
601
+ const remaining = e.files.length - totalCapped;
602
+ const remainingZones = zoneEntries.length - cap;
603
+ lines.push(
604
+ `\n … อีก ${remainingZones} โซน (${remaining} ไฟล์) — fapony debt --id ${e.conv.id} --all`,
605
+ );
547
606
  }
548
607
  }
549
608
  for (const c of report.declared) {
@@ -565,27 +624,66 @@ export function formatDebt(report: DebtReport): string {
565
624
 
566
625
  // --- CLI ---
567
626
 
568
- const USAGE = "usage: fapony debt [path] [--files f1,f2] [--json]";
569
-
570
- function worktreeOf(arg: string | undefined): string {
627
+ const USAGE = `usage: fapony debt [path] [options]
628
+ --files f1,f2 check specific files instead of scanning
629
+ --id <conv> show only this convention
630
+ --where <path> narrow scope to files under this path
631
+ --all show all zones (default: cap at ${ZONE_CAP})
632
+ --json output raw JSON
633
+ -h, --help this help`;
634
+
635
+ /**
636
+ * The dir `debt` measures: the nearest ancestor of `arg` (or cwd) that holds
637
+ * `.fapony/conventions.json`, bounded by the git root.
638
+ *
639
+ * Jumping straight to the git root was the bug: in a monorepo the root has no
640
+ * conventions.json and two apps have one each, so the mem resolver went
641
+ * ambiguous and `debt` said "nothing tracked yet" while
642
+ * apps/<x>/.fapony/conventions.json sat right there — and the positional path
643
+ * argument was silently ignored. Falling back to the git root keeps single
644
+ * repos run from a subdir scanning the whole repo.
645
+ */
646
+ export function worktreeOf(arg: string | undefined): string {
571
647
  const base = resolve(arg ?? ".");
648
+ let gitRoot: string | null = null;
572
649
  try {
573
650
  const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
574
651
  cwd: base,
575
652
  stdout: "pipe",
576
653
  stderr: "pipe",
577
654
  });
578
- if (p.exitCode === 0) return p.stdout.toString().trim();
655
+ if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
579
656
  } catch {
580
- // fall through
657
+ // not a repo — base is all we have
658
+ }
659
+ // `git rev-parse` returns a physical path (/var → /private/var on macOS),
660
+ // so the boundary check compares realpaths, same as the mem resolver.
661
+ const real = (d: string): string => {
662
+ try {
663
+ return realpathSync(d);
664
+ } catch {
665
+ return d;
666
+ }
667
+ };
668
+ const boundary = gitRoot ? real(gitRoot) : null;
669
+ let dir = base;
670
+ while (true) {
671
+ if (existsSync(join(dir, ".fapony", "conventions.json"))) return dir;
672
+ if (boundary && real(dir) === boundary) break;
673
+ const parent = dirname(dir);
674
+ if (parent === dir) break;
675
+ dir = parent;
581
676
  }
582
- return base;
677
+ return gitRoot ?? base;
583
678
  }
584
679
 
585
680
  export function cmdDebt(args: string[]): void {
586
681
  let path: string | undefined;
587
682
  let filesMode: string[] | null = null;
588
683
  let json = false;
684
+ let filterId: string | undefined;
685
+ let wherePath: string | undefined;
686
+ let showAll = false;
589
687
  for (let i = 0; i < args.length; i++) {
590
688
  const a = args[i];
591
689
  if (a === "--files") {
@@ -603,6 +701,24 @@ export function cmdDebt(args: string[]): void {
603
701
  console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
604
702
  process.exit(1);
605
703
  }
704
+ } else if (a === "--id") {
705
+ const v = args[i + 1];
706
+ if (!v || v.startsWith("--")) {
707
+ console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
708
+ process.exit(1);
709
+ }
710
+ i++;
711
+ filterId = v;
712
+ } else if (a === "--where") {
713
+ const v = args[i + 1];
714
+ if (!v || v.startsWith("--")) {
715
+ console.error(`fapony debt: --where needs a path\n${USAGE}`);
716
+ process.exit(1);
717
+ }
718
+ i++;
719
+ wherePath = v;
720
+ } else if (a === "--all") {
721
+ showAll = true;
606
722
  } else if (a === "--json") {
607
723
  json = true;
608
724
  } else if (a === "-h" || a === "--help") {
@@ -653,6 +769,25 @@ export function cmdDebt(args: string[]): void {
653
769
  return;
654
770
  }
655
771
  const report = debtScan(worktree, loaded);
772
+
773
+ // --id filter: keep only the named convention
774
+ if (filterId) {
775
+ report.entries = report.entries.filter((e) => e.conv.id === filterId);
776
+ report.declared = report.declared.filter((c) => c.id === filterId);
777
+ report.checkedCount = 0; // not relevant when filtering
778
+ report.dropped = report.dropped.filter((d) => d.id === filterId);
779
+ }
780
+
781
+ // --where filter: narrow file lists to paths under the given prefix
782
+ if (wherePath) {
783
+ const prefix = wherePath.replace(/\/+$/, "");
784
+ for (const e of report.entries) {
785
+ e.files = e.files.filter(
786
+ (f) => f === prefix || f.startsWith(`${prefix}/`),
787
+ );
788
+ }
789
+ }
790
+
656
791
  if (json) {
657
792
  console.log(
658
793
  JSON.stringify(
@@ -663,7 +798,7 @@ export function cmdDebt(args: string[]): void {
663
798
  );
664
799
  return;
665
800
  }
666
- console.log(formatDebt(report));
801
+ console.log(formatDebt(report, showAll));
667
802
  for (const w of loaded.warnings) console.log(`⚠ ${w}`);
668
803
  for (const l of formatPromotions(findPromotions(worktree, report))) {
669
804
  console.log(l);
@@ -13,6 +13,7 @@ import {
13
13
  planDir,
14
14
  type Run,
15
15
  } from "../db/index.js";
16
+ import { computeHintImpact, type HintImpact, hintLogPath } from "../hook.js";
16
17
  import { type MemRow, readMemLog } from "../memory.js";
17
18
  import { isPassFamily, VERDICT_GRADES } from "../parse.js";
18
19
  import { imputeResult, loadPrices } from "../price/index.js";
@@ -83,6 +84,8 @@ export interface DigestData {
83
84
  /** % of those whose earliest in-period gate passed at round ≤ 1. */
84
85
  round1_pct: number;
85
86
  };
87
+ /** Hint-fire log counts for this worktree — null when no log exists. */
88
+ impact: HintImpact | null;
86
89
  skipped_malformed: number;
87
90
  }
88
91
 
@@ -586,6 +589,18 @@ export async function collectDigest(
586
589
  detail: verdictResult.detail,
587
590
  });
588
591
 
592
+ // 6. hint-fire log — annotate surface, this worktree only. A log that
593
+ // exists but fired 0 in the window is "0", not "no data" (done criteria 6).
594
+ const hasHintLog = existsSync(hintLogPath(worktree));
595
+ const impact = hasHintLog ? computeHintImpact(sinceIso, worktree) : null;
596
+ sources.push({
597
+ name: "hint-log",
598
+ ok: hasHintLog,
599
+ detail: impact
600
+ ? `${impact.fired} fired this window (${impact.debt.shown} debt shown)`
601
+ : "no hints recorded",
602
+ });
603
+
589
604
  // classify mem rows
590
605
  const decisions = mem.rows.filter((r) => r.kind === "decision");
591
606
  const notes = mem.rows.filter((r) => r.kind === "note");
@@ -620,6 +635,7 @@ export async function collectDigest(
620
635
  units_graded: verdictResult.units_graded,
621
636
  round1_pct: verdictResult.round1_pct,
622
637
  },
638
+ impact,
623
639
  skipped_malformed: skippedMalformed,
624
640
  };
625
641
  }
@@ -34,6 +34,23 @@ function fmtTokens(n: number): string {
34
34
  return String(Math.round(n));
35
35
  }
36
36
 
37
+ function impactLines(i: NonNullable<DigestData["impact"]>): string[] {
38
+ const b = i.by_surface;
39
+ const out = [
40
+ `hints fired: ${i.fired} (read ${b.read} · debt ${b.debt} · mem ${b.mem} · commit ${b.commit})`,
41
+ ];
42
+ if (i.debt.shown > 0) {
43
+ const pct = Math.round((i.debt.resolved / i.debt.shown) * 100);
44
+ out.push(
45
+ `debt lines shown: ${i.debt.shown} · no longer present at HEAD: ${i.debt.resolved} (${pct}%) · unverifiable: ${i.debt.unknown}`,
46
+ );
47
+ }
48
+ out.push(
49
+ "note: counts what fapony showed and what the repo looks like now, not proof the agent acted because of the hint",
50
+ );
51
+ return out;
52
+ }
53
+
37
54
  function costLine(c: CostRow): string {
38
55
  const name = c.provider ? `${c.provider}/${c.model}` : c.model;
39
56
  const costStr =
@@ -70,6 +87,14 @@ export function renderDigestText(d: DigestData): string {
70
87
  );
71
88
  lines.push("");
72
89
 
90
+ // 2b. fapony impact — what the annotate surfaces showed, not proof of use
91
+ lines.push(
92
+ section("FAPONY IMPACT", [
93
+ ...(d.impact ? impactLines(d.impact) : ["(no hints recorded)"]),
94
+ ]),
95
+ );
96
+ lines.push("");
97
+
73
98
  // 3. Decisions
74
99
  lines.push(
75
100
  section(`DECISIONS (${d.decisions.length})`, [