fapony 0.3.3 → 0.3.5

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 (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -0,0 +1,180 @@
1
+ // src/commands.ts — the single source of truth for top-level command names.
2
+ //
3
+ // Dispatch stays an if-chain in src/adapters/cli.ts (rule 1: this is a data
4
+ // table, not a framework — no registry class, no plugin hooks). This table
5
+ // owns name + group + one-line summary; `fapony --help` renders from it and
6
+ // test/docs.test.ts fails when a name here is missing from the docs, so the
7
+ // four copies (if-chain, usage, CLAUDE.md, README) cannot drift silently.
8
+ // Zero imports — cli.ts loads this on every startup (≤ ~100ms budget).
9
+
10
+ export type CommandGroup =
11
+ | "core"
12
+ | "usage"
13
+ | "lookup"
14
+ | "hooks"
15
+ | "setup"
16
+ | "frozen";
17
+
18
+ export interface CommandInfo {
19
+ name: string;
20
+ group: CommandGroup;
21
+ summary: string;
22
+ }
23
+
24
+ const GROUP_ORDER: CommandGroup[] = [
25
+ "core",
26
+ "usage",
27
+ "lookup",
28
+ "hooks",
29
+ "setup",
30
+ "frozen",
31
+ ];
32
+
33
+ export const COMMANDS: CommandInfo[] = [
34
+ {
35
+ name: "mem",
36
+ group: "core",
37
+ summary: "project pain memory: decisions, bugs, notes",
38
+ },
39
+ {
40
+ name: "debt",
41
+ group: "core",
42
+ summary: "which files haven't migrated to a declared convention",
43
+ },
44
+ {
45
+ name: "lint-baseline",
46
+ group: "core",
47
+ summary: 'separate "already red" from "I made it red"',
48
+ },
49
+ { name: "init-mem", group: "core", summary: "delete legacy .memory/ dirs" },
50
+ {
51
+ name: "digest",
52
+ group: "core",
53
+ summary: "single-page summary from what's on disk",
54
+ },
55
+ {
56
+ name: "usage-scan",
57
+ group: "usage",
58
+ summary: "scan session logs into the cache",
59
+ },
60
+ { name: "usage-web", group: "usage", summary: "usage dashboard from cache" },
61
+ {
62
+ name: "price-scan",
63
+ group: "usage",
64
+ summary: "refresh the model price table",
65
+ },
66
+ {
67
+ name: "analyze",
68
+ group: "lookup",
69
+ summary: "live repo graph: hubs, orphans, cycles",
70
+ },
71
+ {
72
+ name: "review-seed",
73
+ group: "lookup",
74
+ summary: "read-only scope facts for a review",
75
+ },
76
+ {
77
+ name: "plan-seed",
78
+ group: "lookup",
79
+ summary: "write PLAN (+SPEC) with capped sections",
80
+ },
81
+ {
82
+ name: "mcp",
83
+ group: "hooks",
84
+ summary: "MCP server (stdio JSON-RPC, 3 tools)",
85
+ },
86
+ {
87
+ name: "hook-stop",
88
+ group: "hooks",
89
+ summary: "Stop hook: block turns with commits but no mem row",
90
+ },
91
+ {
92
+ name: "hook-read-hint",
93
+ group: "hooks",
94
+ summary: "read/re-read annotations",
95
+ },
96
+ {
97
+ name: "hook-edit-hint",
98
+ group: "hooks",
99
+ summary: "importer count before editing shape",
100
+ },
101
+ {
102
+ name: "hook-mv-guard",
103
+ group: "hooks",
104
+ summary: "deny raw git mv of plan files into done/",
105
+ },
106
+ {
107
+ name: "hook-session-start",
108
+ group: "hooks",
109
+ summary: "SessionStart: kickoff into context",
110
+ },
111
+ { name: "init", group: "setup", summary: "scaffold .fapony/ in a worktree" },
112
+ {
113
+ name: "install",
114
+ group: "setup",
115
+ summary: "wire MCP + skills into clients",
116
+ },
117
+ { name: "setup", group: "setup", summary: "interactive wizard" },
118
+ { name: "update", group: "setup", summary: "self-update via git pull" },
119
+ { name: "telemetry", group: "setup", summary: "opt-in telemetry show/send" },
120
+ {
121
+ name: "stats",
122
+ group: "frozen",
123
+ summary: "frozen-ledger KPIs (reads history only)",
124
+ },
125
+ { name: "report", group: "frozen", summary: "verification report for a run" },
126
+ { name: "report-web", group: "frozen", summary: "static HTML report page" },
127
+ ];
128
+
129
+ // Grouped names only — the shape PLAN-seed-and-surface §7 picked. Starts with
130
+ // a `usage: fapony` line: scripts/smoke-publish.sh greps for it on a no-arg run.
131
+ export const renderUsage = (): string => {
132
+ const lines = [
133
+ "fapony — project pain memory for agent-written code",
134
+ "usage: fapony <command> [args]",
135
+ "",
136
+ ];
137
+ for (const g of GROUP_ORDER) {
138
+ const names = COMMANDS.filter((c) => c.group === g).map((c) => c.name);
139
+ lines.push(` ${g.padEnd(8)}${names.join(" ")}`);
140
+ }
141
+ lines.push(
142
+ "",
143
+ " fapony mem <sub> --help per-subcommand details (only mem has --help)",
144
+ );
145
+ return lines.join("\n");
146
+ };
147
+
148
+ // Classic two-row Levenshtein — the did-you-mean behind unknown-command errors.
149
+ export const levenshtein = (a: string, b: string): number => {
150
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
151
+ for (let i = 1; i <= a.length; i++) {
152
+ const cur = [i];
153
+ for (let j = 1; j <= b.length; j++) {
154
+ cur[j] = Math.min(
155
+ prev[j] + 1,
156
+ cur[j - 1] + 1,
157
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
158
+ );
159
+ }
160
+ prev = cur;
161
+ }
162
+ return prev[b.length];
163
+ };
164
+
165
+ // Up to 3 suggestions within distance ≤ 2, against top-level names and mem
166
+ // subs (so `fapony plan-check` offers `fapony mem plan-check`). Sorted by
167
+ // distance, then alphabetically — deterministic for tests.
168
+ export const suggestCommand = (cmd: string, memSubs: string[]): string[] => {
169
+ const scored: Array<{ text: string; d: number }> = [];
170
+ for (const c of COMMANDS) {
171
+ const d = levenshtein(cmd, c.name);
172
+ if (d <= 2) scored.push({ text: `fapony ${c.name}`, d });
173
+ }
174
+ for (const s of memSubs) {
175
+ const d = levenshtein(cmd, s);
176
+ if (d <= 2) scored.push({ text: `fapony mem ${s}`, d });
177
+ }
178
+ scored.sort((x, y) => x.d - y.d || (x.text < y.text ? -1 : 1));
179
+ return scored.slice(0, 3).map((s) => s.text);
180
+ };
@@ -15,7 +15,7 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, relative } from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
  import { collectSourceFiles, isSkippedDir, isTestFile } from "./analyze.js";
18
- import { CONVENTIONS_FILE, FAPONY_DIR } from "./db/index.js";
18
+ import { CONVENTIONS_FILE, FAPONY_DIR } from "./core/config.js";
19
19
  import { extractBody, extractExports } from "./map.js";
20
20
 
21
21
  const RESTRICTED_RULES = new Set([
@@ -0,0 +1,199 @@
1
+ // src/core/config.ts — pure config single source (PLAN-lib-layer chunk 4).
2
+ // Merged from db/{defaults,types,getters,load} (shims removed — 0 importers).
3
+ // db/ keeps only store.ts (bun:sqlite).
4
+ // Pure = node builtins only, no imports back to features/adapters/db-store.
5
+
6
+ export { DEFAULT_SAFETY_DENY } from "./defaults.js";
7
+
8
+ // --- Types (ex db/types.ts — ledger frozen, types only, no logic change) ---
9
+
10
+ export type RunStatus =
11
+ | "running"
12
+ | "awaiting_review"
13
+ | "fixing"
14
+ | "passed"
15
+ | "stopped"
16
+ | "stalled";
17
+
18
+ export interface Run {
19
+ id: number;
20
+ worktree: string;
21
+ plan: string | null;
22
+ mem_id: string | null;
23
+ status: RunStatus;
24
+ base_sha: string;
25
+ round: number;
26
+ created_at: string;
27
+ updated_at: string;
28
+ }
29
+
30
+ export interface Event {
31
+ id: number;
32
+ run_id: number;
33
+ ts: string;
34
+ kind: string;
35
+ data: string | null;
36
+ }
37
+
38
+ export interface Config {
39
+ worktrees: Record<string, string>;
40
+ review: {
41
+ maxRounds: number;
42
+ };
43
+ memory: {
44
+ claim: string[];
45
+ close: string[];
46
+ add: string[];
47
+ kickoff?: string[];
48
+ } | null;
49
+ // opt-in only — omit or leave null to keep everything local. See TELEMETRY.md
50
+ // for the exact payload shape (KPI numbers + event kind/timestamp, no
51
+ // plan/commit/gate-note content, ever).
52
+ telemetry?: {
53
+ enabled: boolean;
54
+ endpoint: string;
55
+ /** Self-reported metadata — advisory, not machine-observed. */
56
+ metadata?: {
57
+ task_category?: string;
58
+ stack?: string;
59
+ notes?: string;
60
+ };
61
+ } | null;
62
+ // --- Flexible paths / limits (all optional, defaults = old hardcodes) ---
63
+ paths?: {
64
+ // state dir override (default: $XDG_CONFIG_HOME/fapony or ~/.config/fapony).
65
+ // $FAPONY_STATE_DIR env wins over this when set.
66
+ stateDir?: string;
67
+ // plan/spec live in .fapony/{plan,spec} — not configurable (gitignored = private).
68
+ doneDir?: string;
69
+ memDir?: string;
70
+ evidenceFile?: string;
71
+ } | null;
72
+ safety?: {
73
+ // regex sources tested against the joined argv; default = the 4 git patterns.
74
+ deny?: string[];
75
+ } | null;
76
+ usageWeb?: {
77
+ port?: number;
78
+ hostname?: string;
79
+ ownerName?: string;
80
+ } | null;
81
+ }
82
+
83
+ export interface RolePricing {
84
+ inputPer1k: number;
85
+ outputPer1k: number;
86
+ }
87
+
88
+ // --- Defaults (ex db/defaults.ts) ---
89
+
90
+ // --- .fapony/ layout (single source of truth — do not hardcode ".fapony" elsewhere) ---
91
+ // plan/spec live in .fapony/ — not configurable (gitignored = private).
92
+ export const FAPONY_DIR = ".fapony";
93
+ export const CONFIG_FILENAME = "fapony.config.json";
94
+ export const CONVENTIONS_FILENAME = "conventions.json";
95
+ export const EVIDENCE_FILENAME = "evidence.json";
96
+ export const CONVENTIONS_FILE = `${FAPONY_DIR}/${CONVENTIONS_FILENAME}`;
97
+ // plan/spec live in .fapony/ — not configurable (gitignored = private).
98
+ export const PLAN_DIR = `${FAPONY_DIR}/plan`;
99
+ export const SPEC_DIR = `${FAPONY_DIR}/spec`;
100
+ // Archive sits beside plan/, not inside it, so archiving never changes a file's
101
+ // depth and its relative links survive the move untouched.
102
+ export const DEFAULT_DONE_DIR = `${FAPONY_DIR}/done`;
103
+ export const DEFAULT_MEM_DIR = `${FAPONY_DIR}/.memory`;
104
+ export const DEFAULT_EVIDENCE_FILE = `${FAPONY_DIR}/${EVIDENCE_FILENAME}`;
105
+
106
+ export const DEFAULT_CONFIG: Config = {
107
+ worktrees: {},
108
+ review: {
109
+ maxRounds: 2,
110
+ },
111
+ memory: null,
112
+ telemetry: null,
113
+ paths: null,
114
+ safety: null,
115
+ };
116
+
117
+ // --- Getters (ex db/getters.ts — centralized, no hardcode at call site) ---
118
+
119
+ import { DEFAULT_SAFETY_DENY } from "./defaults.js";
120
+
121
+ export function safetyDeny(config?: Config): string[] {
122
+ return config?.safety?.deny ?? DEFAULT_SAFETY_DENY;
123
+ }
124
+
125
+ /** Hardcoded — plan/spec live in .fapony/ (gitignored = private). */
126
+ export function planDir(): string {
127
+ return PLAN_DIR;
128
+ }
129
+
130
+ /** Hardcoded — plan/spec live in .fapony/ (gitignored = private). */
131
+ export function specDir(): string {
132
+ return SPEC_DIR;
133
+ }
134
+
135
+ export function doneDir(config?: Config): string {
136
+ return config?.paths?.doneDir ?? DEFAULT_DONE_DIR;
137
+ }
138
+
139
+ export function memoryDir(config?: Config): string {
140
+ return config?.paths?.memDir ?? DEFAULT_MEM_DIR;
141
+ }
142
+
143
+ export function evidenceFile(config?: Config): string {
144
+ return config?.paths?.evidenceFile ?? DEFAULT_EVIDENCE_FILE;
145
+ }
146
+
147
+ // --- Load (ex db/load.ts — fs/env only, no sqlite) ---
148
+
149
+ import { existsSync, readFileSync } from "node:fs";
150
+ import { homedir } from "node:os";
151
+ import { join } from "node:path";
152
+
153
+ // XDG Base Directory convention (macOS ignores Apple's ~/Library/Application Support
154
+ // for CLI tools by common practice — gh, ripgrep-adjacent tools, etc. use ~/.config too)
155
+ // Override order: $FAPONY_STATE_DIR > config.paths.stateDir > $XDG_CONFIG_HOME > ~/.config
156
+ export function faponyDir(config?: Config): string {
157
+ if (process.env.FAPONY_STATE_DIR) return process.env.FAPONY_STATE_DIR;
158
+ if (config?.paths?.stateDir) return config.paths.stateDir;
159
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
160
+ return join(base, "fapony");
161
+ }
162
+
163
+ export function configFilePath(): string {
164
+ if (process.env.FAPONY_CONFIG) return process.env.FAPONY_CONFIG;
165
+ return join(process.cwd(), CONFIG_FILENAME);
166
+ }
167
+
168
+ function freshDefaultConfig(): Config {
169
+ return {
170
+ ...DEFAULT_CONFIG,
171
+ worktrees: { ...DEFAULT_CONFIG.worktrees },
172
+ review: {
173
+ ...DEFAULT_CONFIG.review,
174
+ },
175
+ };
176
+ }
177
+
178
+ export function loadConfig(configPath?: string): Config {
179
+ const resolved = configPath ?? configFilePath();
180
+ if (!existsSync(resolved)) return freshDefaultConfig();
181
+
182
+ try {
183
+ const raw = readFileSync(resolved, "utf-8");
184
+ const file = JSON.parse(raw) as Partial<Config>;
185
+
186
+ return {
187
+ ...DEFAULT_CONFIG,
188
+ ...file,
189
+ review: {
190
+ ...DEFAULT_CONFIG.review,
191
+ ...file.review,
192
+ },
193
+ paths: file.paths ? { ...file.paths } : DEFAULT_CONFIG.paths,
194
+ safety: file.safety ? { ...file.safety } : DEFAULT_CONFIG.safety,
195
+ };
196
+ } catch {
197
+ return freshDefaultConfig();
198
+ }
199
+ }
@@ -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 "./debt-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,79 @@
1
+ // src/debt/types.ts — shared types + caps for `fapony debt`.
2
+ //
3
+ // One convention = pattern to use (ok) + pattern meaning not-yet-migrated
4
+ // (stale) + scope (where) + file condition (guard). Iron rule: checker not
5
+ // null = fapony never reports that debt item — reporting twice with eslint
6
+ // is an abstraction with one implementation (rule 1).
7
+
8
+ export interface Convention {
9
+ id: string;
10
+ rule: string;
11
+ /** Repo-relative dir scope ("." = whole repo). */
12
+ where: string;
13
+ /** Regex source: a match means the file still has the debt. null = not derivable (checker rows) or not filled in yet. */
14
+ stale: string | null;
15
+ /** Regex source: files that already moved (informational count). */
16
+ ok?: string;
17
+ /** Regex source a file must ALSO match to be in scope (e.g. "extends Base"). */
18
+ guard?: string;
19
+ /** Non-null = a checker (eslint rule / script) exists → fapony never reports this debt. */
20
+ checker?: string | null;
21
+ /** Human answered "no checker" on the promotion question — never ask again. */
22
+ decided?: "no-checker" | null;
23
+ }
24
+
25
+ export interface LoadedConventions {
26
+ path: string | null;
27
+ convs: Convention[];
28
+ /** Rows kept for display but not scannable, plus invalid rows — said out loud, never silent. */
29
+ warnings: string[];
30
+ }
31
+
32
+ export interface DebtEntry {
33
+ conv: Convention;
34
+ /** Files with the debt (stale match), sorted. */
35
+ files: string[];
36
+ /** Files that already moved (ok match) — null when ok is not set. */
37
+ movedCount: number | null;
38
+ }
39
+
40
+ export interface DebtReport {
41
+ worktree: string;
42
+ scannedFiles: number;
43
+ ms: number;
44
+ /** Scannable conventions with their debt list (checker rows never land here). */
45
+ entries: DebtEntry[];
46
+ /** Declared but not fillable by fapony: checker null + no stale — the human/agent fills `stale`. */
47
+ declared: Convention[];
48
+ /** Skipped-with-reason: checker rows are silent by design (not dropped), these are real drops. */
49
+ dropped: { id: string; reason: string }[];
50
+ /** Silent-by-design count: checker non-null — reported as a number, not a list. */
51
+ checkedCount: number;
52
+ }
53
+
54
+ export interface Promotion {
55
+ convId: string;
56
+ rule: string;
57
+ occurrences: number;
58
+ dates: string[];
59
+ debtCount: number;
60
+ }
61
+
62
+ // A stale regex matching more than this many files is not a convention — it is
63
+ // a broken/wide regex (stale="e" would flag the repo). SPEC §6: drop the entry
64
+ // and say so, never report 600 files.
65
+ export const DEBT_FILE_CAP = 250;
66
+
67
+ export const PROMOTION_THRESHOLD = 3;
68
+ export const PROMOTION_MAX = 3;
69
+ /** Identifiers shorter than this are too generic to match prose on ("throw", "Error"). */
70
+ export const WORD_MIN = 6;
71
+
72
+ // Zone grouping: a zone is a file's *directory*, capped at this many leading
73
+ // segments — never a fixed-depth prefix of the path (which would cut into the
74
+ // filename) and never the filename itself. SPEC §4 shows zones at depth 5
75
+ // (`apps/mdl/src/server/services`) and depth 3 (`packages/cache/src`) in the
76
+ // same report, so the cap must follow the directory, not a constant.
77
+ export const ZONE_DEPTH = 5;
78
+ // Default cap on zones shown per convention — more than this is a wall, not an answer.
79
+ export const ZONE_CAP = 6;
@@ -0,0 +1,8 @@
1
+ // src/core/defaults.ts — shared constants (pure, no db/state deps)
2
+
3
+ export const DEFAULT_SAFETY_DENY = [
4
+ "reset\\s+--hard",
5
+ "clean\\s+-[a-z]*f",
6
+ "checkout\\s+--\\s",
7
+ "git\\s+stash",
8
+ ];
@@ -0,0 +1,34 @@
1
+ // src/core/enums.ts — ReasonCode + RegimeCode enums (pure constants, no imports)
2
+ //
3
+ // Extracted from src/mcp/types.ts (PLAN-lib-layer chunk 2c) to break the
4
+ // stats→mcp dependency. These enums are used by stats/data.ts, mcp/types.ts,
5
+ // and context/projectHealth.ts — core is the neutral home.
6
+
7
+ // --- ReasonCode enum (locked in step 0, append-only) ---
8
+
9
+ export const REASON_CODES = [
10
+ "missing_test",
11
+ "scope_mismatch",
12
+ "unsafe_command",
13
+ "spec_gap",
14
+ "timeout",
15
+ "blocked",
16
+ "incomplete",
17
+ "none",
18
+ "other",
19
+ ] as const;
20
+
21
+ export type ReasonCode = (typeof REASON_CODES)[number];
22
+
23
+ // --- RegimeCode enum (task-shape axis for model × project × regime × quality) ---
24
+
25
+ export const REGIME_CODES = [
26
+ "code",
27
+ "fix",
28
+ "review",
29
+ "plan",
30
+ "inquiry",
31
+ "test",
32
+ ] as const;
33
+
34
+ export type RegimeCode = (typeof REGIME_CODES)[number];
@@ -0,0 +1,33 @@
1
+ // src/web/html.ts — shared HTML helpers for report-web and usage-web
2
+
3
+ /** HTML-escape interpolated strings. */
4
+ export function esc(s: string): string {
5
+ return s
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;");
10
+ }
11
+
12
+ /** Common dark-theme CSS variables + base reset used by all HTML pages. */
13
+ export const DARK_THEME_CSS = `
14
+ :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; --muted: #8b949e; }
15
+ * { box-sizing: border-box; margin: 0; padding: 0; }
16
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; padding: 2rem; max-width: 960px; margin: 0 auto; }
17
+ `;
18
+
19
+ /** Shared table + typography CSS used by both report and usage pages. */
20
+ export const TABLE_CSS = `
21
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
22
+ h2 { font-size: 1.1rem; color: var(--accent); margin: 1.5rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
23
+ .meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; }
24
+ table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.9rem; }
25
+ th, td { padding: 0.5rem 0.8rem; text-align: left; border-bottom: 1px solid var(--border); }
26
+ th { color: var(--muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; }
27
+ tr:hover { background: #161b22; }
28
+ .pass { color: var(--green); }
29
+ .fail { color: var(--red); }
30
+ .warn { color: var(--yellow); }
31
+ .muted { color: var(--muted); }
32
+ .sample { font-size: 0.8rem; color: var(--muted); }
33
+ `;