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.
- package/README.md +75 -44
- package/fapony.ts +32 -1
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +13 -1
- package/src/conventions-seed.ts +0 -1
- package/src/db/defaults.ts +1 -1
- package/src/db/getters.ts +3 -3
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +1 -1
- package/src/debt.ts +169 -34
- package/src/digest/collect.ts +16 -0
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +424 -16
- package/src/init-mem.ts +123 -37
- package/src/init.ts +51 -34
- package/src/install/claude.ts +7 -5
- package/src/install/opencode.ts +59 -5
- package/src/lint-baseline.ts +1 -7
- package/src/mcp/evidence.ts +1 -1
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +348 -0
- package/src/memory.ts +237 -40
- package/src/plan-seed.ts +20 -1
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +4 -6
- package/src/stats/data.ts +34 -86
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
527
|
-
return
|
|
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 =
|
|
538
|
-
|
|
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
|
-
|
|
545
|
-
if (e.
|
|
546
|
-
lines.push(`
|
|
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 =
|
|
569
|
-
|
|
570
|
-
|
|
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)
|
|
655
|
+
if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
|
|
579
656
|
} catch {
|
|
580
|
-
//
|
|
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);
|
package/src/digest/collect.ts
CHANGED
|
@@ -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
|
}
|
package/src/digest/text.ts
CHANGED
|
@@ -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})`, [
|