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