fapony 0.2.1 → 0.3.3

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 (54) hide show
  1. package/README.md +95 -72
  2. package/fapony.ts +12 -5
  3. package/package.json +5 -4
  4. package/skill/define-convention/SKILL.md +77 -0
  5. package/skill/lookup-before-edit/SKILL.md +48 -0
  6. package/skill/move-to-done/SKILL.md +19 -30
  7. package/skill/review-pony/SKILL.md +38 -61
  8. package/src/analyze.ts +1 -1
  9. package/src/debt/cli.ts +193 -0
  10. package/src/debt/format.ts +107 -0
  11. package/src/debt/index.ts +19 -0
  12. package/src/debt/load.ts +92 -0
  13. package/src/debt/promotion.ts +152 -0
  14. package/src/debt/scan.ts +214 -0
  15. package/src/debt/types.ts +79 -0
  16. package/src/detect.ts +92 -0
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +349 -100
  19. package/src/init-mem.ts +57 -71
  20. package/src/init.ts +12 -16
  21. package/src/install/antigravity.ts +112 -0
  22. package/src/install/claude.ts +16 -123
  23. package/src/install/codex.ts +58 -19
  24. package/src/install/detect.ts +17 -7
  25. package/src/install/opencode.ts +131 -6
  26. package/src/install.ts +12 -3
  27. package/src/lint-baseline.ts +2 -2
  28. package/src/mcp/primitives.ts +1 -1
  29. package/src/mcp/tools/index.ts +13 -102
  30. package/src/mcp/tools/mem.ts +71 -0
  31. package/src/mcp/transport.ts +8 -94
  32. package/src/mcp/worktree.ts +1 -1
  33. package/src/mem/commands/read.ts +231 -141
  34. package/src/mem/index.ts +4 -13
  35. package/src/memory.ts +17 -8
  36. package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
  37. package/src/seed/primitives.ts +66 -0
  38. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  39. package/src/session/helpers.ts +1 -1
  40. package/src/session/registry.ts +3 -6
  41. package/src/setup.ts +1 -1
  42. package/src/stats/data.ts +1 -1
  43. package/src/telemetry.ts +1 -1
  44. package/src/util.ts +61 -0
  45. package/templates/SPEC.md +8 -1
  46. package/images/logo.png +0 -0
  47. package/images/logo.webp +0 -0
  48. package/images/logo@400.webp +0 -0
  49. package/images/sample.webp +0 -0
  50. package/images/summary.webp +0 -0
  51. package/src/debt.ts +0 -811
  52. package/src/math.ts +0 -13
  53. package/src/mcp/tools/usage.ts +0 -211
  54. package/src/mcp/tools/verdict.ts +0 -161
@@ -1,4 +1,4 @@
1
- // src/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
1
+ // src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
2
2
  //
3
3
  // Writes PLAN + SPEC straight into planDir/specDir. What it pre-fills is the
4
4
  // structure (frontmatter, the 8 sections, prior art, ledger context) — the
@@ -25,7 +25,6 @@
25
25
  //
26
26
  // Composes existing producers — no new parsing, no new table, no MCP tool.
27
27
 
28
- import { execSync } from "node:child_process";
29
28
  import {
30
29
  existsSync,
31
30
  mkdirSync,
@@ -35,15 +34,16 @@ import {
35
34
  writeFileSync,
36
35
  } from "node:fs";
37
36
  import { basename, join, relative, resolve, sep } from "node:path";
38
- import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "./analyze.js";
39
- import { computeModelFit } from "./context/projectHealth.js";
40
- import { doneDir, planDir, specDir } from "./db/getters.js";
41
- import { CONFIG_FILENAME } from "./db/index.js";
42
- import { loadConfig } from "./db/load.js";
43
- import type { Config } from "./db/types.js";
44
- import { extractExports } from "./map.js";
45
- import { readRecentMemDecisions } from "./memory.js";
46
- import { getStatsData } from "./stats/data.js";
37
+ import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "../analyze.js";
38
+ import { computeModelFit } from "../context/projectHealth.js";
39
+ import { doneDir, planDir, specDir } from "../db/getters.js";
40
+ import { CONFIG_FILENAME } from "../db/index.js";
41
+ import { loadConfig } from "../db/load.js";
42
+ import type { Config } from "../db/types.js";
43
+ import { extractExports } from "../map.js";
44
+ import { readRecentMemDecisions } from "../memory.js";
45
+ import { getStatsData } from "../stats/data.js";
46
+ import { capLines, execGit, SIG_MAX } from "./primitives.js";
47
47
 
48
48
  // One chunk = one module's signatures — past ~40 lines a module is its own
49
49
  // reading task, and the whole-SPEC cap below does the final trim.
@@ -56,7 +56,6 @@ const SCOPE_WARN_FILES = 300;
56
56
  // Shipped plans/specs that already touched this scope. Capped low on purpose:
57
57
  // this is a "go read that first" pointer, not a bibliography.
58
58
  const MAX_PRIOR_ART = 5;
59
- const SIG_MAX = 90;
60
59
  // Anchor-safe slug: lowercase, non-alphanumerics → dash.
61
60
  const slug = (s: string): string =>
62
61
  s
@@ -224,7 +223,7 @@ next step in the same session is what rule 9 forbids.
224
223
  1. _(agent fills in — each step must be verifiable)_
225
224
 
226
225
  **Closing a step:** tick its TL;DR box with the sha · \`git commit\` this step's
227
- files only · \`verdict_submit\` (MCP) with this step's \`regime\` · then hand off:
226
+ files only · then hand off:
228
227
 
229
228
  \`\`\`bash
230
229
  fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}
@@ -257,16 +256,6 @@ interface Chunk {
257
256
  body: string;
258
257
  }
259
258
 
260
- // review-seed's cap shape: keep the head, always say how much was cut — a
261
- // silent cut is indistinguishable from "that was everything".
262
- function capLines(lines: string[], cap: number, what: string): string[] {
263
- if (lines.length <= cap) return lines;
264
- const rest = lines.length - (cap - 1);
265
- const kept = lines.slice(0, cap - 1);
266
- kept.push(`… +${rest} more ${what}`);
267
- return kept;
268
- }
269
-
270
259
  function capChunk(c: Chunk): Chunk {
271
260
  return {
272
261
  ...c,
@@ -530,15 +519,8 @@ export function cmdPlanSeed(args: string[]): void {
530
519
  // state.db uses (git rev-parse --show-toplevel). Running from a subdir
531
520
  // would otherwise mismatch: stats return empty, Context (fapony) always
532
521
  // prints "(not enough graded history yet)".
533
- let worktree: string;
534
- try {
535
- worktree = execSync("git rev-parse --show-toplevel", {
536
- encoding: "utf-8",
537
- stdio: ["pipe", "pipe", "pipe"],
538
- }).trim();
539
- } catch {
540
- worktree = cwd;
541
- }
522
+ const root = execGit("git rev-parse --show-toplevel", cwd);
523
+ const worktree = root.ok ? root.output.split("\n")[0] : cwd;
542
524
 
543
525
  // Scope: explicit paths win; default is the cwd. Resolved absolutes,
544
526
  // deduped — the same path twice is one scope. Nested roots are pruned:
@@ -0,0 +1,66 @@
1
+ // src/seed/primitives.ts — shared helpers for plan-seed + review-seed
2
+ //
3
+ // Git execution, output capping, signature formatting — the pieces both
4
+ // commands need. §0 rule: add-only — never remove or rename exported symbols.
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ // --- Error class ---
9
+
10
+ export class SeedError extends Error {}
11
+
12
+ // --- Git helpers ---
13
+
14
+ export function execGit(
15
+ cmd: string,
16
+ cwd: string,
17
+ ): { ok: boolean; output: string; error?: string } {
18
+ try {
19
+ const output = execSync(cmd, {
20
+ cwd,
21
+ encoding: "utf-8",
22
+ stdio: ["pipe", "pipe", "pipe"],
23
+ timeout: 15_000,
24
+ });
25
+ return { ok: true, output: output.trim() };
26
+ } catch (e: unknown) {
27
+ const err = e as { stderr?: string; message?: string };
28
+ return {
29
+ ok: false,
30
+ output: "",
31
+ error: (err.stderr ?? err.message ?? "").trim(),
32
+ };
33
+ }
34
+ }
35
+
36
+ export function gitOk(r: { ok: boolean; error?: string }, cmd: string): void {
37
+ if (!r.ok)
38
+ throw new SeedError(`git failed: ${cmd}\n${r.error ?? "unknown error"}`);
39
+ }
40
+
41
+ // Values interpolated into a git command line must be plain refs/paths —
42
+ // blocks shell metacharacters before execSync ever sees them.
43
+ const GIT_VALUE_RE = /^[A-Za-z0-9._/{}^~+-]+$/;
44
+
45
+ export function gitValue(kind: string, value: string): string {
46
+ if (!GIT_VALUE_RE.test(value)) {
47
+ throw new SeedError(`invalid ${kind}: ${value}`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ // --- Output capping ---
53
+
54
+ // Keep the head, always say how much was cut — a silent cut is
55
+ // indistinguishable from "that was everything".
56
+ export function capLines(lines: string[], cap: number, what: string): string[] {
57
+ if (lines.length <= cap) return lines;
58
+ const rest = lines.length - (cap - 1);
59
+ const kept = lines.slice(0, cap - 1);
60
+ kept.push(`… +${rest} more ${what}`);
61
+ return kept;
62
+ }
63
+
64
+ // --- Signature formatting ---
65
+
66
+ export const SIG_MAX = 90;
@@ -1,4 +1,4 @@
1
- // src/review-seed.ts — `fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>]`
1
+ // src/seed/review-seed.ts — `fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>]`
2
2
  //
3
3
  // Seeds a code review with the deterministic facts of the scope the agent
4
4
  // asked about: which files changed (per the exact git expression, echoed),
@@ -12,14 +12,13 @@
12
12
  // cite rule 5 "never write into a target worktree", dropped 2026-09-17 because
13
13
  // four commands broke it; being read-only was always a property of this
14
14
  // command, never of that rule. Facts only: nothing here says broken/fixed
15
- // — judgment lives in the reviewer and the ledger (verdict_submit), never in
15
+ // — judgment lives in the reviewer and mem log, never in
16
16
  // this output. Deterministic: same input, same bytes, no LLM.
17
17
  //
18
18
  // Composes existing producers — buildGraph (analyze.ts) for importers/untested,
19
19
  // extractExports (map.ts) for signatures. One flag = one declared git call;
20
20
  // no magic parsing.
21
21
 
22
- import { execSync } from "node:child_process";
23
22
  import type { Stats } from "node:fs";
24
23
  import { existsSync, readFileSync, statSync } from "node:fs";
25
24
  import { join } from "node:path";
@@ -30,9 +29,10 @@ import {
30
29
  isTestedThroughBarrels,
31
30
  isTestFile,
32
31
  SCAN_EXTS,
33
- } from "./analyze.js";
34
- import { extractBody, extractExports } from "./map.js";
35
- import { assertSafe } from "./safety.js";
32
+ } from "../analyze.js";
33
+ import { extractBody, extractExports } from "../map.js";
34
+ import { assertSafe } from "../safety.js";
35
+ import { execGit, gitOk, gitValue, SeedError, SIG_MAX } from "./primitives.js";
36
36
 
37
37
  const WRAP_WIDTH = 88;
38
38
  // The changed list is the review's scope boundary, not context: a file hidden
@@ -58,7 +58,6 @@ const LOOKUP_OUTPUT_CAP = 120;
58
58
  // files is already past "this component area" into "the whole tree" — cut
59
59
  // there and say so, a folder-shaped wall is not an answer either.
60
60
  // Signature text cap per symbol (same trim as map.ts's file view).
61
- const SIG_MAX = 90;
62
61
  const DISCLAIMER =
63
62
  "static graph only — seed is where to enter, not what is verified";
64
63
  const USAGE =
@@ -70,52 +69,6 @@ const MAX_BODY_LINES = 80;
70
69
  const MAX_CALLER_FILES = 12;
71
70
  const MAX_CALLER_HITS = 20;
72
71
 
73
- export class SeedError extends Error {}
74
-
75
- // --- Git helpers (same shape as collect.ts execGitSafe) ---
76
-
77
- function execGit(
78
- cmd: string,
79
- cwd: string,
80
- ): { ok: boolean; output: string; error?: string } {
81
- try {
82
- const output = execSync(cmd, {
83
- cwd,
84
- encoding: "utf-8",
85
- stdio: ["pipe", "pipe", "pipe"],
86
- timeout: 15_000,
87
- });
88
- return { ok: true, output: output.trim() };
89
- } catch (e: unknown) {
90
- const err = e as { stderr?: string; message?: string };
91
- return {
92
- ok: false,
93
- output: "",
94
- error: (err.stderr ?? err.message ?? "").trim(),
95
- };
96
- }
97
- }
98
-
99
- // A scope's primary git call failing must never masquerade as "nothing
100
- // changed" — surface it. merge-base failures are tolerated (label fallback).
101
- function gitOk(r: { ok: boolean; error?: string }, cmd: string): void {
102
- if (!r.ok)
103
- throw new SeedError(
104
- `review-seed: git failed: ${cmd}\n${r.error ?? "unknown error"}`,
105
- );
106
- }
107
-
108
- // Values interpolated into a git command line must be plain refs/paths —
109
- // blocks shell metacharacters before execSync ever sees them.
110
- const GIT_VALUE_RE = /^[A-Za-z0-9._/{}^~+-]+$/;
111
-
112
- function gitValue(kind: string, value: string): string {
113
- if (!GIT_VALUE_RE.test(value)) {
114
- throw new SeedError(`review-seed: invalid ${kind}: ${value}`);
115
- }
116
- return value;
117
- }
118
-
119
72
  // --- Scope flags: exactly one source of scope ---
120
73
 
121
74
  type Scope =
@@ -233,7 +186,7 @@ function parseScope(args: string[]): Scope {
233
186
 
234
187
  // --- Scope resolution: one flag = one declared git call ---
235
188
 
236
- interface FileEntry {
189
+ export interface FileEntry {
237
190
  path: string;
238
191
  ins: number | null;
239
192
  del: number | null;
@@ -270,7 +270,7 @@ export function readDetailFromDb(
270
270
  .all(...filter.params) as DetailPerSessionToolRow[];
271
271
 
272
272
  // Context bytes per tool — summed `state.output` length. Same signal the
273
- // Claude Code/Codex readers derive from tool_result blocks, so `fapony_usage`
273
+ // Claude Code/Codex readers derive from tool_result blocks, so usage-scan
274
274
  // stops reporting zero bytes for the SQLite clients.
275
275
  const bytesRows = db
276
276
  .prepare(
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // Adding a client: write its reader (src/session/<name>.ts, same
4
4
  // PassiveUsageResult shape as an existing one — SQLite or JSONL, whatever the
5
- // client actually stores), then add one entry below. usage-scan and
6
- // fapony_usage both iterate this list; neither needs any other change.
5
+ // client actually stores), then add one entry below. usage-scan iterates
6
+ // this list; nothing else needs any other change.
7
7
  //
8
8
  // This does not remove the real work of a new client (reverse-engineering
9
9
  // its storage format) — it only removes the "wire it into 2 call sites"
@@ -16,12 +16,10 @@ import type { PassiveUsageReader } from "./types.js";
16
16
  import { readZcodeUsage } from "./zcode.js";
17
17
 
18
18
  export interface ClientAdapter {
19
- /** cache `client` field and fapony_usage JSON key. */
19
+ /** usage-cache.jsonl `client` field and usage-scan label default. */
20
20
  key: string;
21
21
  /** usage-scan progress label — defaults to `key`. */
22
22
  scanLabel?: string;
23
- /** fapony_usage text-report section label — defaults to `key`. */
24
- reportLabel?: string;
25
23
  /** The one client whose totals lead the report as the unlabeled top-level summary. */
26
24
  primary?: boolean;
27
25
  read: PassiveUsageReader;
@@ -38,7 +36,6 @@ export const CLIENTS: ClientAdapter[] = [
38
36
  {
39
37
  key: "claude_code",
40
38
  scanLabel: "claude-code",
41
- reportLabel: "claude code",
42
39
  read: readClaudeCodeUsage,
43
40
  },
44
41
  { key: "codex", read: readCodexUsage },
package/src/setup.ts CHANGED
@@ -229,7 +229,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
229
229
  │ fapony install --platform claude │
230
230
  │ │
231
231
  │ 2. Ask your agent: │
232
- │ "Run fapony_stats and fapony_usage" │
232
+ │ "Find past decisions with mem_find" │
233
233
  │ │
234
234
  │ 3. Read a run's report: │
235
235
  │ fapony report <run-id> │
package/src/stats/data.ts CHANGED
@@ -5,7 +5,6 @@ import { join } from "node:path";
5
5
  import { type Event, openDb, PLAN_DIR, type Run } from "../db/index.js";
6
6
  import { loadConfig } from "../db/load.js";
7
7
  import { enrichGateWindows } from "../gates.js";
8
- import { avg, minutesBetween } from "../math.js";
9
8
  import { REASON_CODES, REGIME_CODES } from "../mcp/types.js";
10
9
  import {
11
10
  isPassFamily,
@@ -20,6 +19,7 @@ import {
20
19
  readPassiveUsage,
21
20
  readZcodeUsage,
22
21
  } from "../session/index.js";
22
+ import { avg, minutesBetween } from "../util.js";
23
23
 
24
24
  // Walks events per run in order and pairs up spawn→route (executor time)
25
25
  // and route→gate (review turnaround) per round, since one run row can span
package/src/telemetry.ts CHANGED
@@ -17,8 +17,8 @@ import {
17
17
  type Run,
18
18
  } from "./db/index.js";
19
19
  import { enrichGateWindows } from "./gates.js";
20
- import { avg, minutesBetween } from "./math.js";
21
20
  import { readPassiveUsage } from "./session/index.js";
21
+ import { avg, minutesBetween } from "./util.js";
22
22
 
23
23
  // ─── Schema version ────────────────────────────────────────────────────
24
24
 
package/src/util.ts CHANGED
@@ -25,8 +25,69 @@ export function fillPrompt(
25
25
  return out;
26
26
  }
27
27
 
28
+ /** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
29
+ export function minutesBetween(a: string, b: string): number {
30
+ const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
31
+ const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
32
+ return (t1 - t0) / 60000;
33
+ }
34
+
35
+ /** Arithmetic mean of a numeric array. 0 for empty arrays. */
36
+ export function avg(xs: number[]): number {
37
+ return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
38
+ }
39
+
28
40
  /** True for "y"/"yes" (case-insensitive, trimmed) — the only affirmative answers. */
29
41
  export function isAffirmative(answer: string): boolean {
30
42
  const normalized = answer.trim().toLowerCase();
31
43
  return normalized === "y" || normalized === "yes";
32
44
  }
45
+
46
+ // --- Recursive directory walker ---
47
+
48
+ import { readdirSync } from "node:fs";
49
+ import { join } from "node:path";
50
+
51
+ export interface WalkDirOpts {
52
+ /** Max recursion depth (default 4). */
53
+ depth?: number;
54
+ /** Skip node_modules (default true). */
55
+ skipNodeModules?: boolean;
56
+ /** Dot-directories to descend into (default []). Others starting with . are skipped. */
57
+ includeDotDirs?: string[];
58
+ /** Extra filter: return true to collect this dir. Omit to collect all reached dirs. */
59
+ predicate?: (dirPath: string) => boolean;
60
+ }
61
+
62
+ /**
63
+ * Recursively walk directories under `root`.
64
+ * Returns absolute paths of dirs for which `predicate` returned true (or all
65
+ * reached dirs when no predicate is given). Skips node_modules by default.
66
+ */
67
+ export function walkDir(root: string, opts?: WalkDirOpts): string[] {
68
+ const maxDepth = opts?.depth ?? 4;
69
+ const skipNm = opts?.skipNodeModules ?? true;
70
+ const includeDot = new Set(opts?.includeDotDirs ?? []);
71
+ const pred = opts?.predicate;
72
+ const result: string[] = [];
73
+
74
+ const walk = (dir: string, depth: number): void => {
75
+ if (depth > maxDepth) return;
76
+ if (!pred || pred(dir)) result.push(dir);
77
+ try {
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ if (!entry.isDirectory()) continue;
80
+ if (entry.isSymbolicLink()) continue;
81
+ if (skipNm && entry.name === "node_modules") continue;
82
+ if (entry.name.startsWith(".") && !includeDot.has(entry.name)) {
83
+ continue;
84
+ }
85
+ walk(join(dir, entry.name), depth + 1);
86
+ }
87
+ } catch {
88
+ // ignore unreadable dirs
89
+ }
90
+ };
91
+ walk(root, 0);
92
+ return result;
93
+ }
package/templates/SPEC.md CHANGED
@@ -14,11 +14,18 @@
14
14
 
15
15
  ---
16
16
 
17
+ ## Non-goals
18
+ What this explicitly does **not** do, one line of why each. Widening scope is an
19
+ agent's default; a spec that only says what to build never stopped anything.
20
+
17
21
  ## Shape (data / API / schema)
18
22
  Concrete types, request/response bodies, DB columns — whatever the code needs.
19
23
 
20
24
  ## Edge cases
21
- Table or bullets: input → expected behavior.
25
+ Three columns: input → expected → **verify** (a command that actually runs).
26
+ An edge case with no check after it is an opinion, not a requirement — and a
27
+ verify built from a substring of the prose is not a verify (real case: `grep "sed"`
28
+ matched "superseded", so the check passed while the work was missing).
22
29
 
23
30
  ## Examples
24
31
  Before / after, request / response, sample payloads — as long as it needs to be.
package/images/logo.png DELETED
Binary file
package/images/logo.webp DELETED
Binary file
Binary file
Binary file
Binary file