fapony 0.2.1 → 0.3.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 +9 -8
- package/fapony.ts +10 -3
- package/package.json +1 -1
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/review-pony/SKILL.md +11 -3
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/hook.ts +168 -6
- package/src/install/claude.ts +14 -0
- package/src/install/codex.ts +58 -19
- package/src/install/opencode.ts +98 -0
- package/src/lint-baseline.ts +2 -2
- package/src/mcp/tools/index.ts +13 -28
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +12 -63
- package/src/mem/commands/read.ts +18 -1
- package/src/memory.ts +17 -8
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/debt.ts +0 -811
- package/src/mcp/tools/usage.ts +0 -211
package/README.md
CHANGED
|
@@ -122,7 +122,6 @@ fapony usage-scan # scan the session logs already on dis
|
|
|
122
122
|
fapony price-scan # fetch the OpenRouter price table → ~/.config/fapony/prices.json
|
|
123
123
|
fapony usage-web # dashboard; re-run the scans to refresh
|
|
124
124
|
# both scans are manual by design — nothing fetches or re-reads session logs behind your back
|
|
125
|
-
# ask your agent: "Run fapony_usage — what has it cost me, per model?"
|
|
126
125
|
|
|
127
126
|
# 4. Verify (optional, per project) — scaffold the evidence allowlist
|
|
128
127
|
fapony init /path/to/your-worktree
|
|
@@ -185,7 +184,7 @@ is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP
|
|
|
185
184
|
|
|
186
185
|
| | Claude Code | OpenCode | Cursor | ZCode | Codex |
|
|
187
186
|
|---|---|---|---|---|---|
|
|
188
|
-
| MCP tools — `mem_find` `mem_add` `
|
|
187
|
+
| MCP tools — `mem_find` `mem_add` `mem_close` `verdict_submit` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
189
188
|
| Stop hook — refuse to end a turn with ungraded commits | ✅ | — | ✅ | — | ✅ after trust |
|
|
190
189
|
| Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — |
|
|
191
190
|
| Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — |
|
|
@@ -240,17 +239,17 @@ count, once per session, before you change its shape; OpenCode's **commit** hook
|
|
|
240
239
|
|
|
241
240
|
| Tool | Tier | Purpose |
|
|
242
241
|
|------|------|---------|
|
|
243
|
-
| `fapony_usage` | measure | Passive usage from OpenCode, ZCode, Claude Code, and Codex sessions (tokens, cost, by-model; `detail:true` adds per-step timing) |
|
|
244
242
|
| `verdict_submit` | verify | Store a 6-grade verdict (pass-excellent → uncertain) with a required `regime` — the task shape the grade applies to |
|
|
245
243
|
| `mem_find` | recall | Search the project's mem log read-only — decisions/bugs/notes matched on the row's `files[]` (text substring for rows written without it), `text`, `kind` (no default filter), `since`. "What was ever decided about this file?" in one call before editing |
|
|
246
244
|
| `mem_add` | recall | Append a mem row (decision/bug/note/next/hold) with `files[]` required and rejected when empty — the write half of `mem_find`, so the row is findable when you next touch that file |
|
|
245
|
+
| `mem_close` | recall | Close a mem row by id with a tombstone message — a separate tool (not `kind:"close"`) because a close row carries no `files[]`, so sharing `mem_add`'s schema would make required fields depend on another field's value |
|
|
247
246
|
|
|
248
247
|
**A tool earns its schema by being called mid-task without being asked.** Everything you invoke
|
|
249
248
|
deliberately is a CLI command instead: the schema is paid as input tokens in every session of
|
|
250
249
|
every client whether or not it is used, while a CLI command costs nothing until it runs. That is
|
|
251
|
-
why the handoff/report family is CLI-only, and why `fapony_stats`, `project_health_context
|
|
252
|
-
`plan_list` left the MCP surface in 2026-09 (`fapony stats` answers the first, `fapony mem
|
|
253
|
-
kickoff` the third; the second had no caller).
|
|
250
|
+
why the handoff/report family is CLI-only, and why `fapony_stats`, `project_health_context`,
|
|
251
|
+
`plan_list` and `fapony_usage` left the MCP surface in 2026-09 (`fapony stats` answers the first, `fapony mem
|
|
252
|
+
kickoff` the third, `fapony usage-web` the fourth; the second had no caller).
|
|
254
253
|
Cutting is not the goal — spending where it pays back is: `mem_find` and `verdict_submit` keep
|
|
255
254
|
their schemas because nobody is going to type them at the right moment. `fapony report <run-id>` prints the full report for a run (facts + handoff conformance + evidence + verdict); `fapony report-web [file]` renders it as a static HTML page (overwrites `file` on every call — safe to reuse the same path). Run `bun run overview` for a one-shot shortcut that writes it to `/tmp/fapony-overview.html` and opens it. `fapony usage-scan` scans session logs and writes a cache file; `fapony usage-web [port]` serves a static HTML dashboard from that cache (no live scanning). Run `fapony usage-scan` periodically to keep data fresh.
|
|
256
255
|
|
|
@@ -304,13 +303,15 @@ That is the whole trick; there is no model in the middle.
|
|
|
304
303
|
|
|
305
304
|
### Skills
|
|
306
305
|
|
|
307
|
-
fapony ships
|
|
306
|
+
fapony ships seven portable skills, each as `skill/<name>/SKILL.md` — the layout Claude
|
|
308
307
|
Code expects, so a client can symlink the directory rather than copy the file:
|
|
309
308
|
|
|
310
309
|
| Skill | Purpose | Trigger |
|
|
311
310
|
|-------|---------|---------|
|
|
312
311
|
| `skill/plan-with-pony/` | Draft plan + spec from "what's in your head" via conversation | `/plan-with-pony` |
|
|
313
312
|
| `skill/review-pony/` | Review as verification, wired to fapony: scope facts before (`review-seed`), verdict after | `/review-pony` |
|
|
313
|
+
| `skill/lookup-before-edit/` | Look up unfamiliar files (`review-seed --files` + mem + debt) before reading/editing them | `/lookup-before-edit` |
|
|
314
|
+
| `skill/define-convention/` | Turn a not-yet-migrated pattern into a tracked convention (interview + dry-run `debt`) | `/define-convention` |
|
|
314
315
|
| `skill/move-to-done/` | Archive a shipped PLAN into .fapony/done/ | `/move-to-done` |
|
|
315
316
|
| `skill/git-commit-conventional/` | Commit split by concern + conventional message | `/git-commit` |
|
|
316
317
|
| `skill/git-ship/` | Push branch, open PR with drafted title/body, merge, reset branch onto base | `/ship`, `/pr` |
|
|
@@ -363,7 +364,7 @@ plain `/git-ship` detects that and behaves like `pr` on its own.
|
|
|
363
364
|
|
|
364
365
|
**What this is not.** It doesn't reduce your token bill — an agent that plans against known
|
|
365
366
|
failure patterns tends to spend fewer rounds getting there, but fapony measures that, it doesn't
|
|
366
|
-
cause it. Use `
|
|
367
|
+
cause it. Use `fapony usage-web` to find out whether it actually happened for you rather than taking
|
|
367
368
|
the claim on faith.
|
|
368
369
|
|
|
369
370
|
`fapony install --platform claude` (or `opencode`) symlinks these directories into
|
package/fapony.ts
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { cmdAnalyze } from "./src/analyze.js";
|
|
8
|
-
import { cmdDebt } from "./src/debt.js";
|
|
8
|
+
import { cmdDebt } from "./src/debt/cli.js";
|
|
9
9
|
import { cmdDigest } from "./src/digest/cli.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
cmdHookEditHint,
|
|
12
|
+
cmdHookReadHint,
|
|
13
|
+
cmdHookSessionStart,
|
|
14
|
+
cmdHookStop,
|
|
15
|
+
} from "./src/hook.js";
|
|
11
16
|
import { cmdInit } from "./src/init.js";
|
|
12
17
|
import { cmdInitMem } from "./src/init-mem.js";
|
|
13
18
|
import { cmdInstall } from "./src/install.js";
|
|
@@ -87,6 +92,8 @@ if (cmd === "analyze") {
|
|
|
87
92
|
await cmdHookReadHint();
|
|
88
93
|
} else if (cmd === "hook-edit-hint") {
|
|
89
94
|
await cmdHookEditHint();
|
|
95
|
+
} else if (cmd === "hook-session-start") {
|
|
96
|
+
await cmdHookSessionStart();
|
|
90
97
|
} else if (cmd === "mcp") {
|
|
91
98
|
cmdMcp();
|
|
92
99
|
} else if (cmd === "report") {
|
|
@@ -108,7 +115,7 @@ if (cmd === "analyze") {
|
|
|
108
115
|
} else {
|
|
109
116
|
console.error(`fapony: unknown command "${cmd ?? ""}"`);
|
|
110
117
|
console.error(
|
|
111
|
-
"usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|test> [args]",
|
|
118
|
+
"usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|hook-session-start|test> [args]",
|
|
112
119
|
);
|
|
113
120
|
process.exit(1);
|
|
114
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Measurement layer for coding agents \u2014 measure what agents do, verify what they claim. 4 MCP tools, any agent, no loop required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: define-convention
|
|
3
|
+
description: Turn "files that haven't migrated yet" into a tracked convention — one question, a draft built from real examples, then dry-run fapony debt until the counts hold. Trigger on /define-convention and when the user wants migration tracking or fapony debt shows declared rows with no regex.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Define Convention — from pain to a counted migration
|
|
7
|
+
|
|
8
|
+
One convention = the pattern to use (`ok`) + the pattern meaning not-yet-migrated
|
|
9
|
+
(`stale`) + scope (`where`) + an optional file condition (`guard`). The output is
|
|
10
|
+
one row in `<worktree>/.fapony/conventions.json` (`{"conventions": [...]}`), in the
|
|
11
|
+
same `.fapony/` dir as the mem log — run `fapony mem where`, go up one level, that
|
|
12
|
+
is where the file lives (app-scoped in a monorepo). No file there yet = create it;
|
|
13
|
+
a file with rows = append only, never rewrite other rows.
|
|
14
|
+
|
|
15
|
+
## Phase 1 — One question, then the checker question
|
|
16
|
+
|
|
17
|
+
Ask this, and nothing else:
|
|
18
|
+
|
|
19
|
+
> "What should stop appearing, and what does the migrated code look like? Which
|
|
20
|
+
> directory is it in?"
|
|
21
|
+
|
|
22
|
+
Then the iron-rule question (one line, always asked — a convention eslint already
|
|
23
|
+
flags is one fapony must stay silent on):
|
|
24
|
+
|
|
25
|
+
> "Does an eslint rule or script already flag the old pattern? If yes, name it —
|
|
26
|
+
> fapony will record it as `checker` and never report this debt."
|
|
27
|
+
|
|
28
|
+
## Phase 2 — Real examples before regex
|
|
29
|
+
|
|
30
|
+
Never invent the regex from prose — regex from prose is how entries get dropped.
|
|
31
|
+
`grep` the `where` dir for 2–3 hits of the old pattern and 1–2 of the new one. No
|
|
32
|
+
hits on either side = stop and say so; there is no migration to track yet, only
|
|
33
|
+
an opinion.
|
|
34
|
+
|
|
35
|
+
## Phase 3 — Draft the row, show it, append it
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"id": "raw-throw", "rule": "throw failWith, not raw Error",
|
|
39
|
+
"where": "src", "stale": "throw new Error", "ok": "failWith"}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `id` short, kebab; `rule` one human line; `where` a repo-relative dir that
|
|
43
|
+
exists (`"."` = whole repo).
|
|
44
|
+
- `guard` only when `stale` alone is too broad (the file must ALSO match, e.g.
|
|
45
|
+
`"extends Base"`).
|
|
46
|
+
- `checker` set from Phase 1 = silent by design; verify it with eslint, not fapony.
|
|
47
|
+
- `stale: null` ships a `declared` placeholder — fill it or delete it, never keep it.
|
|
48
|
+
- The shown draft is the confirmation (rule 6c). Append, don't rewrite.
|
|
49
|
+
|
|
50
|
+
## Phase 4 — Dry-run until the counts hold
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
fapony debt --id <new-id>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Read it literally — every outcome names its fix:
|
|
57
|
+
|
|
58
|
+
- `debt N · moved M` with N > 0 → the convention now counts. Report id, N, moved%
|
|
59
|
+
back in chat.
|
|
60
|
+
- `debt 0 · moved M — clean` → the migration is already done; nothing left to
|
|
61
|
+
track. Report it and change nothing.
|
|
62
|
+
- `debt 0` with no moved → `stale` matches nothing you care about; widen it or
|
|
63
|
+
fix `where`.
|
|
64
|
+
- `⚠ <id>: ... too broad` → narrow `stale`, shrink `where`, or add `guard` (the
|
|
65
|
+
match was capped past 250 files, so the entry was dropped).
|
|
66
|
+
- `⚠ <id>: stale regex broken` / `where: <dir> does not exist` → fix syntax / path.
|
|
67
|
+
- A row prints `declared, no checker, stale not filled in` → `stale` is null; see
|
|
68
|
+
Phase 3.
|
|
69
|
+
- `0 convention(s)` after `--id` → that id does not exist (misspelling — a dropped
|
|
70
|
+
row still prints its `⚠`). Only `no conventions.json in <dir>` means you wrote
|
|
71
|
+
to the wrong `.fapony/` (re-check `mem where`).
|
|
72
|
+
|
|
73
|
+
## Later
|
|
74
|
+
|
|
75
|
+
A convention whose fix recurs ≥3 times makes `fapony debt` ask "time for a
|
|
76
|
+
checker?" — that promotion answers to a human, never to this skill. `no-checker`
|
|
77
|
+
means "never ask again": say it only deliberately.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lookup-before-edit
|
|
3
|
+
description: Look up unfamiliar files before reading or editing them — exports, line numbers, and importers without reading the whole file. Trigger on /lookup-before-edit and proactively whenever you are about to read, edit, or refactor a file you do not already know.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lookup Before Edit — scope first, read second
|
|
7
|
+
|
|
8
|
+
You are about to touch files you do not know. Do not `Read` them whole — look them up first.
|
|
9
|
+
A full-file read on a 500-line module costs ~35k tokens of output; a lookup costs under 1k.
|
|
10
|
+
|
|
11
|
+
## The one command
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
fapony review-seed --files <f1,f2,dir> [--body <sym>] [--callers <sym>]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
What it returns: every export with its line number (uncapped), plus the importers — first 12
|
|
18
|
+
per file, the rest as `(+N)`, so the total is still readable. That is your entry map — then
|
|
19
|
+
`Read` only the line ranges you actually need.
|
|
20
|
+
|
|
21
|
+
## Narrow it
|
|
22
|
+
|
|
23
|
+
- `--body <sym>[,<sym>]` — declaration slice of those exports (truncated at 80 lines each):
|
|
24
|
+
"what does this do" without the file.
|
|
25
|
+
- `--callers <sym>` — symbol→symbol scan across the importers the static graph sees.
|
|
26
|
+
- Directories expand to the source files under them (cap 40, stated when cut). Paths that do
|
|
27
|
+
not exist are dropped with a notice, not counted silently.
|
|
28
|
+
|
|
29
|
+
## Limits (do not work around them)
|
|
30
|
+
|
|
31
|
+
- **Exports only.** A non-exported function answers "no export named X in scope" — that is
|
|
32
|
+
the correct answer, not a failure. Read the file for internals.
|
|
33
|
+
- **Static only.** Dynamic use is invisible to the graph; an empty caller list means "not
|
|
34
|
+
seen statically", never "unused".
|
|
35
|
+
- **120 lines total.** Past that the tail is cut (`… (+N lines truncated)`) — and the tail is
|
|
36
|
+
the later files' signatures. Many files at once → split the call, don't trust a cut list.
|
|
37
|
+
- No `fapony` CLI or the call errors → read the file normally and carry on. A hint, not a gate.
|
|
38
|
+
|
|
39
|
+
## History + debt (same paths, two calls)
|
|
40
|
+
|
|
41
|
+
- `mem_find` with `files: [<same paths>]` — "what was ever decided about this file" (MCP, no CLI spawn).
|
|
42
|
+
- `fapony debt --where <dir|file>` — conventions this path still violates; empty until `conventions.json` exists.
|
|
43
|
+
|
|
44
|
+
## After the lookup
|
|
45
|
+
|
|
46
|
+
1. Pick line ranges from the export list, `Read` those slices only.
|
|
47
|
+
2. Before changing a shape, sum shown + `(+N)` importers — that is your blast radius.
|
|
48
|
+
3. Do not re-read a file whose mtime has not moved; `grep` it instead.
|
|
@@ -117,10 +117,15 @@ them is how a review launders an assumption into a fact.
|
|
|
117
117
|
|
|
118
118
|
The reader has the diff and is deciding what to do next. Nothing else belongs here.
|
|
119
119
|
|
|
120
|
-
**Verdict first, then at most
|
|
120
|
+
**Verdict first, then at most 10 findings, at most 4 lines each, then one deferred line.**
|
|
121
121
|
Severity order: blocker → major → nit, and cut the nits entirely when anything structural
|
|
122
122
|
survived — they dilute the only thing worth reading.
|
|
123
123
|
|
|
124
|
+
**A capped report must not read as a complete one.** When more findings survived pass 3 than
|
|
125
|
+
the cap allows, the report ends with one line naming how many were held back and the worst
|
|
126
|
+
severity among them (`+ 4 more (major) — ask`) — never a silent stop at 10. A reader who
|
|
127
|
+
cannot tell "that's all" from "that's the cap" has been told a lie by omission.
|
|
128
|
+
|
|
124
129
|
```
|
|
125
130
|
<ship | fix-then-ship | rework | reject> — the single biggest reason, one sentence.
|
|
126
131
|
|
|
@@ -130,6 +135,7 @@ survived — they dilute the only thing worth reading.
|
|
|
130
135
|
fix: <the minimal change>
|
|
131
136
|
|
|
132
137
|
deferred: <thing> (<where it was specified>) · <thing>
|
|
138
|
+
+ <N> more (<worst severity>) ← required only when findings exceeded the cap
|
|
133
139
|
```
|
|
134
140
|
|
|
135
141
|
Four lines is a ceiling, not a quota — a finding that fits in two ships in two. Drop `repro:`
|
|
@@ -214,9 +220,11 @@ The four passes are the rules. These three are what they fail on in practice:
|
|
|
214
220
|
|
|
215
221
|
- **Order is not optional.** No line-by-line notes before pass 1, no finding before passes 2-3
|
|
216
222
|
earned it, nothing stated as fact that pass 4 cannot cite.
|
|
217
|
-
- **The budget is binding.** Verdict, ≤
|
|
223
|
+
- **The budget is binding.** Verdict, ≤10 findings, ≤4 lines each, one deferred line. Over budget
|
|
218
224
|
means you are reporting process. "LGTM" is not an output either — finding nothing ships as the
|
|
219
|
-
verdict line plus one line naming what you walked.
|
|
225
|
+
verdict line plus one line naming what you walked. When the cap bites, say how much it bit:
|
|
226
|
+
a `+ N more (<severity>)` line is required — a silent stop at 10 is the same lie as a silent
|
|
227
|
+
stop at 3.
|
|
220
228
|
- **Forget who wrote it.** The author's reasoning is context, never evidence.
|
|
221
229
|
|
|
222
230
|
## Example
|
package/src/debt/cli.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/debt/cli.ts — `fapony debt` CLI: arg parsing + worktree resolution.
|
|
2
|
+
//
|
|
3
|
+
// Read-only stdout: no file writes, no state.db, no cache (rule 5b).
|
|
4
|
+
|
|
5
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { CONVENTIONS_FILE } from "../db/defaults.js";
|
|
8
|
+
import { formatDebt } from "./format.js";
|
|
9
|
+
import { loadConventions } from "./load.js";
|
|
10
|
+
import { findPromotions, formatPromotions } from "./promotion.js";
|
|
11
|
+
import { debtForFile, debtScan } from "./scan.js";
|
|
12
|
+
import { ZONE_CAP } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage: fapony debt [path] [options]
|
|
15
|
+
--files f1,f2 check specific files instead of scanning
|
|
16
|
+
--id <conv> show only this convention
|
|
17
|
+
--where <path> narrow scope to files under this path
|
|
18
|
+
--all show all zones (default: cap at ${ZONE_CAP})
|
|
19
|
+
--json output raw JSON
|
|
20
|
+
-h, --help this help`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The dir `debt` measures: the nearest ancestor of `arg` (or cwd) that holds
|
|
24
|
+
* `.fapony/conventions.json`, bounded by the git root.
|
|
25
|
+
*
|
|
26
|
+
* Jumping straight to the git root was the bug: in a monorepo the root has no
|
|
27
|
+
* conventions.json and two apps have one each, so the mem resolver went
|
|
28
|
+
* ambiguous and `debt` said "nothing tracked yet" while
|
|
29
|
+
* apps/<x>/.fapony/conventions.json sat right there — and the positional path
|
|
30
|
+
* argument was silently ignored. Falling back to the git root keeps single
|
|
31
|
+
* repos run from a subdir scanning the whole repo.
|
|
32
|
+
*/
|
|
33
|
+
export function worktreeOf(arg: string | undefined): string {
|
|
34
|
+
const base = resolve(arg ?? ".");
|
|
35
|
+
let gitRoot: string | null = null;
|
|
36
|
+
try {
|
|
37
|
+
const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
38
|
+
cwd: base,
|
|
39
|
+
stdout: "pipe",
|
|
40
|
+
stderr: "pipe",
|
|
41
|
+
});
|
|
42
|
+
if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
|
|
43
|
+
} catch {
|
|
44
|
+
// not a repo — base is all we have
|
|
45
|
+
}
|
|
46
|
+
// `git rev-parse` returns a physical path (/var → /private/var on macOS),
|
|
47
|
+
// so the boundary check compares realpaths, same as the mem resolver.
|
|
48
|
+
const real = (d: string): string => {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(d);
|
|
51
|
+
} catch {
|
|
52
|
+
return d;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const boundary = gitRoot ? real(gitRoot) : null;
|
|
56
|
+
let dir = base;
|
|
57
|
+
while (true) {
|
|
58
|
+
if (existsSync(join(dir, CONVENTIONS_FILE))) return dir;
|
|
59
|
+
if (boundary && real(dir) === boundary) break;
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) break;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
return gitRoot ?? base;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cmdDebt(args: string[]): void {
|
|
68
|
+
let path: string | undefined;
|
|
69
|
+
let filesMode: string[] | null = null;
|
|
70
|
+
let json = false;
|
|
71
|
+
let filterId: string | undefined;
|
|
72
|
+
let wherePath: string | undefined;
|
|
73
|
+
let showAll = false;
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === "--files") {
|
|
77
|
+
const v = args[i + 1];
|
|
78
|
+
if (!v || v.startsWith("--")) {
|
|
79
|
+
console.error(`fapony debt: --files needs a value\n${USAGE}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
i++;
|
|
83
|
+
filesMode = v
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((s) => s.trim())
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
if (filesMode.length === 0) {
|
|
88
|
+
console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
} else if (a === "--id") {
|
|
92
|
+
const v = args[i + 1];
|
|
93
|
+
if (!v || v.startsWith("--")) {
|
|
94
|
+
console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
i++;
|
|
98
|
+
filterId = v;
|
|
99
|
+
} else if (a === "--where") {
|
|
100
|
+
const v = args[i + 1];
|
|
101
|
+
if (!v || v.startsWith("--")) {
|
|
102
|
+
console.error(`fapony debt: --where needs a path\n${USAGE}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
i++;
|
|
106
|
+
wherePath = v;
|
|
107
|
+
} else if (a === "--all") {
|
|
108
|
+
showAll = true;
|
|
109
|
+
} else if (a === "--json") {
|
|
110
|
+
json = true;
|
|
111
|
+
} else if (a === "-h" || a === "--help") {
|
|
112
|
+
console.log(USAGE);
|
|
113
|
+
return;
|
|
114
|
+
} else if (!a.startsWith("--")) {
|
|
115
|
+
path = a;
|
|
116
|
+
} else {
|
|
117
|
+
console.error(`fapony debt: unknown argument "${a}"\n${USAGE}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const worktree = worktreeOf(path);
|
|
123
|
+
const loaded = loadConventions(worktree);
|
|
124
|
+
|
|
125
|
+
if (filesMode) {
|
|
126
|
+
const out = filesMode.map((f) => {
|
|
127
|
+
const abs = isAbsolute(f) ? f : resolve(worktree, f);
|
|
128
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
129
|
+
return { file: f, debt: [], note: "not found" as const };
|
|
130
|
+
}
|
|
131
|
+
return { file: f, debt: debtForFile(worktree, abs, loaded) };
|
|
132
|
+
});
|
|
133
|
+
if (json) {
|
|
134
|
+
console.log(JSON.stringify({ worktree, files: out }, null, 2));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let any = false;
|
|
138
|
+
for (const r of out) {
|
|
139
|
+
for (const c of r.debt) {
|
|
140
|
+
any = true;
|
|
141
|
+
console.log(`${r.file} — ${c.id}: ${c.rule}`);
|
|
142
|
+
}
|
|
143
|
+
if ("note" in r) console.log(`${r.file} — ${r.note}`);
|
|
144
|
+
}
|
|
145
|
+
if (!any && out.every((r) => r.debt.length === 0)) {
|
|
146
|
+
console.log("no convention debt in the given file(s)");
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (loaded.path === null) {
|
|
152
|
+
// SPEC §6: no conventions.json = completely silent, no error, no prompt to create one
|
|
153
|
+
console.log(
|
|
154
|
+
`fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const report = debtScan(worktree, loaded);
|
|
159
|
+
|
|
160
|
+
// --id filter: keep only the named convention
|
|
161
|
+
if (filterId) {
|
|
162
|
+
report.entries = report.entries.filter((e) => e.conv.id === filterId);
|
|
163
|
+
report.declared = report.declared.filter((c) => c.id === filterId);
|
|
164
|
+
report.checkedCount = 0; // not relevant when filtering
|
|
165
|
+
report.dropped = report.dropped.filter((d) => d.id === filterId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --where filter: narrow file lists to paths under the given prefix
|
|
169
|
+
if (wherePath) {
|
|
170
|
+
const prefix = wherePath.replace(/\/+$/, "");
|
|
171
|
+
for (const e of report.entries) {
|
|
172
|
+
e.files = e.files.filter(
|
|
173
|
+
(f) => f === prefix || f.startsWith(`${prefix}/`),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (json) {
|
|
179
|
+
console.log(
|
|
180
|
+
JSON.stringify(
|
|
181
|
+
{ ...report, promotions: findPromotions(worktree, report) },
|
|
182
|
+
null,
|
|
183
|
+
2,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
console.log(formatDebt(report, showAll));
|
|
189
|
+
for (const w of loaded.warnings) console.log(`⚠ ${w}`);
|
|
190
|
+
for (const l of formatPromotions(findPromotions(worktree, report))) {
|
|
191
|
+
console.log(l);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/debt/format.ts — report rendering: zone grouping + text output.
|
|
2
|
+
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { type DebtReport, ZONE_CAP, ZONE_DEPTH } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** The zone of a file: its directory path, capped at `depth` segments. */
|
|
7
|
+
function zoneOf(file: string, depth: number): string {
|
|
8
|
+
const parts = dirname(file)
|
|
9
|
+
.split("/")
|
|
10
|
+
.filter((p) => p && p !== ".");
|
|
11
|
+
return parts.slice(0, depth).join("/") || ".";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Group files by their directory zone (see `zoneOf`). */
|
|
15
|
+
function groupFilesByZone(
|
|
16
|
+
files: string[],
|
|
17
|
+
depth: number,
|
|
18
|
+
): Map<string, string[]> {
|
|
19
|
+
const zones = new Map<string, string[]>();
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
const zone = zoneOf(f, depth);
|
|
22
|
+
const cur = zones.get(zone) ?? [];
|
|
23
|
+
cur.push(f);
|
|
24
|
+
zones.set(zone, cur);
|
|
25
|
+
}
|
|
26
|
+
// Sort zones by file count descending, then alphabetically
|
|
27
|
+
return new Map(
|
|
28
|
+
[...zones.entries()].sort((a, b) => {
|
|
29
|
+
const d = b[1].length - a[1].length;
|
|
30
|
+
return d !== 0 ? d : a[0].localeCompare(b[0]);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Escape a regex source for use in a shell grep command. */
|
|
36
|
+
function shellEscapeRe(src: string): string {
|
|
37
|
+
return src.replace(/'/g, "'\\''");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatDebt(report: DebtReport, showAll = false): string {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
lines.push(
|
|
43
|
+
`fapony debt — ${report.entries.length + report.declared.length + report.checkedCount} convention(s), ` +
|
|
44
|
+
`${report.scannedFiles} files scanned, ${report.ms}ms — derived fresh, not stored`,
|
|
45
|
+
);
|
|
46
|
+
for (const e of report.entries) {
|
|
47
|
+
const moved =
|
|
48
|
+
e.movedCount !== null && e.files.length > 0
|
|
49
|
+
? ` · moved ${e.movedCount} (${Math.round((e.movedCount / (e.files.length + e.movedCount)) * 100)}%)`
|
|
50
|
+
: e.movedCount !== null
|
|
51
|
+
? ` · moved ${e.movedCount}`
|
|
52
|
+
: "";
|
|
53
|
+
lines.push(`\n${e.conv.id} — ${e.conv.rule}`);
|
|
54
|
+
// Show the patterns actually used
|
|
55
|
+
const patterns: string[] = [];
|
|
56
|
+
if (e.conv.stale) patterns.push(`stale: ${e.conv.stale}`);
|
|
57
|
+
if (e.conv.ok) patterns.push(`ok: ${e.conv.ok}`);
|
|
58
|
+
if (e.conv.guard) patterns.push(`guard: ${e.conv.guard}`);
|
|
59
|
+
patterns.push(`where ${e.conv.where}`);
|
|
60
|
+
lines.push(` ${patterns.join(" · ")}`);
|
|
61
|
+
if (e.files.length === 0) {
|
|
62
|
+
lines.push(` debt 0${moved} — clean`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
lines.push(` debt ${e.files.length}${moved}`);
|
|
66
|
+
// Verify command derived from stale
|
|
67
|
+
if (e.conv.stale) {
|
|
68
|
+
lines.push(` verify: grep -rn '${shellEscapeRe(e.conv.stale)}' <zone>`);
|
|
69
|
+
}
|
|
70
|
+
// Zone grouping
|
|
71
|
+
const zones = groupFilesByZone(e.files, ZONE_DEPTH);
|
|
72
|
+
const zoneEntries = [...zones.entries()];
|
|
73
|
+
const cap = showAll
|
|
74
|
+
? zoneEntries.length
|
|
75
|
+
: Math.min(zoneEntries.length, ZONE_CAP);
|
|
76
|
+
let totalCapped = 0;
|
|
77
|
+
for (let i = 0; i < cap; i++) {
|
|
78
|
+
const [zone, zoneFiles] = zoneEntries[i];
|
|
79
|
+
const pad = " ".repeat(Math.max(0, 42 - zone.length));
|
|
80
|
+
lines.push(`\n ${zone}${pad}${zoneFiles.length} ไฟล์`);
|
|
81
|
+
lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
|
|
82
|
+
totalCapped += zoneFiles.length;
|
|
83
|
+
}
|
|
84
|
+
if (zoneEntries.length > cap) {
|
|
85
|
+
const remaining = e.files.length - totalCapped;
|
|
86
|
+
const remainingZones = zoneEntries.length - cap;
|
|
87
|
+
lines.push(
|
|
88
|
+
`\n … อีก ${remainingZones} โซน (${remaining} ไฟล์) — fapony debt --id ${e.conv.id} --all`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const c of report.declared) {
|
|
93
|
+
lines.push(`\n${c.id} — ${c.rule} (where ${c.where})`);
|
|
94
|
+
lines.push(
|
|
95
|
+
` declared, no checker, stale not filled in — fill "stale" in conventions.json`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (report.checkedCount > 0) {
|
|
99
|
+
lines.push(
|
|
100
|
+
`\n${report.checkedCount} convention(s) have a checker — fapony stays silent, the checker reports`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
for (const d of report.dropped) {
|
|
104
|
+
lines.push(`⚠ ${d.id}: ${d.reason}`);
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/debt/index.ts — barrel for `fapony debt`: which files have not moved
|
|
2
|
+
// to a shipped convention yet.
|
|
3
|
+
//
|
|
4
|
+
// The question nobody can answer: "which files have not moved" — rules files
|
|
5
|
+
// (CLAUDE.md, Cursor rules) can only say "what the rule is" (layer 2) and
|
|
6
|
+
// "which files were copied" (layer 1) — where the debt is (layer 3) lives in
|
|
7
|
+
// the owner's head and vanishes when forgotten (SPEC-convention-debt §1)
|
|
8
|
+
//
|
|
9
|
+
// The convention definition lives in the measured repo — fapony does not know
|
|
10
|
+
// React or Hono and must not. Layout mirrors src/mcp/: types + one file per
|
|
11
|
+
// concern, CLI entry in cli.ts (fapony.ts imports that directly, same as
|
|
12
|
+
// digest/cli.ts).
|
|
13
|
+
|
|
14
|
+
export * from "./cli.js";
|
|
15
|
+
export * from "./format.js";
|
|
16
|
+
export * from "./load.js";
|
|
17
|
+
export * from "./promotion.js";
|
|
18
|
+
export * from "./scan.js";
|
|
19
|
+
export * from "./types.js";
|