fapony 0.3.3 → 0.3.4

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 (81) hide show
  1. package/fapony.ts +3 -116
  2. package/package.json +2 -2
  3. package/skill/move-to-done/SKILL.md +3 -2
  4. package/src/adapters/cli.ts +123 -0
  5. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  6. package/src/adapters/hooks/context-data.ts +102 -0
  7. package/src/adapters/hooks/edit-hint.ts +195 -0
  8. package/src/adapters/hooks/git-autonomy.ts +178 -0
  9. package/src/adapters/hooks/index.ts +79 -0
  10. package/src/adapters/hooks/mv-guard.ts +52 -0
  11. package/src/adapters/hooks/read-hint.ts +383 -0
  12. package/src/adapters/hooks/session-start.ts +101 -0
  13. package/src/adapters/hooks/stop.ts +302 -0
  14. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  15. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  17. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  19. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  20. package/src/adapters/mcp/types.ts +18 -0
  21. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  22. package/src/analyze.ts +1 -1
  23. package/src/conventions-seed.ts +1 -1
  24. package/src/core/config.ts +199 -0
  25. package/src/core/debt-format.ts +107 -0
  26. package/src/core/debt-types.ts +79 -0
  27. package/src/core/defaults.ts +8 -0
  28. package/src/core/enums.ts +34 -0
  29. package/src/core/format.ts +33 -0
  30. package/src/core/hint-log.ts +68 -0
  31. package/src/core/hook-helpers.ts +31 -0
  32. package/src/core/mem-log.ts +357 -0
  33. package/src/core/parse.ts +71 -0
  34. package/src/core/pricing.ts +217 -0
  35. package/src/core/safety.ts +18 -0
  36. package/src/core/types.ts +142 -0
  37. package/src/core/util.ts +105 -0
  38. package/src/db/store.ts +2 -2
  39. package/src/debt/cli.ts +1 -1
  40. package/src/debt/format.ts +2 -107
  41. package/src/debt/load.ts +1 -1
  42. package/src/debt/promotion.ts +1 -1
  43. package/src/debt/types.ts +14 -79
  44. package/src/digest/collect.ts +4 -3
  45. package/src/gate.ts +2 -2
  46. package/src/gates.ts +1 -1
  47. package/src/hook.ts +69 -1424
  48. package/src/init-mem.ts +1 -1
  49. package/src/init.ts +1 -1
  50. package/src/install/claude.ts +17 -0
  51. package/src/install/opencode.ts +114 -0
  52. package/src/install.ts +12 -5
  53. package/src/lint-baseline.ts +1 -2
  54. package/src/map.ts +29 -8
  55. package/src/mem/commands/plan.ts +70 -42
  56. package/src/mem/store.ts +5 -1
  57. package/src/memory.ts +24 -387
  58. package/src/parse.ts +9 -71
  59. package/src/price/fetch.ts +4 -16
  60. package/src/price/resolve.ts +12 -213
  61. package/src/report/cli.ts +3 -3
  62. package/src/safety.ts +2 -18
  63. package/src/seed/plan-seed.ts +8 -4
  64. package/src/seed/primitives.ts +1 -7
  65. package/src/session/types.ts +14 -128
  66. package/src/setup.ts +1 -1
  67. package/src/stats/data.ts +3 -3
  68. package/src/telemetry.ts +2 -2
  69. package/src/usage/cache.ts +1 -2
  70. package/src/usage/cli.ts +1 -1
  71. package/src/usage/scan.ts +2 -1
  72. package/src/util.ts +10 -93
  73. package/src/web/html.ts +2 -33
  74. package/src/db/defaults.ts +0 -34
  75. package/src/db/getters.ts +0 -35
  76. package/src/db/index.ts +0 -7
  77. package/src/db/load.ts +0 -57
  78. package/src/db/types.ts +0 -77
  79. package/src/mcp/types.ts +0 -54
  80. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  81. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -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
+ `;
@@ -0,0 +1,68 @@
1
+ // src/core/hint-log.ts — hint-fire log path + write + types (pure, no feature imports)
2
+ //
3
+ // Extracted from src/hook.ts (PLAN-lib-layer chunk 2a) to break the
4
+ // digest→hook coupling. computeHintImpact stays in hook.ts because it
5
+ // imports debtForFile (feature layer).
6
+
7
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const HINT_LOG_DIR = "hint-log";
12
+
13
+ /** Stable filename key from an absolute worktree path. */
14
+ export function worktreeKey(worktree: string): string {
15
+ return worktree.replace(/^\/+/, "").replace(/\//g, "--");
16
+ }
17
+
18
+ /** Directory holding one hint-fire log file per worktree. */
19
+ export function hintLogDir(): string {
20
+ const base =
21
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
22
+ return join(base, HINT_LOG_DIR);
23
+ }
24
+
25
+ /** Absolute path of a worktree's hint-fire log — may not exist. */
26
+ export function hintLogPath(worktree: string): string {
27
+ return join(hintLogDir(), `${worktreeKey(worktree)}.jsonl`);
28
+ }
29
+
30
+ export interface HintFireRow {
31
+ ts: string;
32
+ worktree: string;
33
+ surface: "read" | "debt" | "mem" | "commit" | "edit";
34
+ file: string | null;
35
+ count: number;
36
+ ids?: string[];
37
+ }
38
+
39
+ /**
40
+ * Append a hint-fire log row. Best-effort: never throws, never blocks.
41
+ * Uses $FAPONY_STATE_DIR when set (tests, CI), otherwise ~/.config/fapony.
42
+ */
43
+ export function recordHintFire(row: HintFireRow): void {
44
+ try {
45
+ const dir = hintLogDir();
46
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
47
+ appendFileSync(
48
+ hintLogPath(row.worktree),
49
+ `${JSON.stringify(row)}\n`,
50
+ "utf-8",
51
+ );
52
+ } catch {
53
+ // best-effort — swallow
54
+ }
55
+ }
56
+
57
+ export interface HintImpact {
58
+ fired: number;
59
+ by_surface: {
60
+ read: number;
61
+ debt: number;
62
+ mem: number;
63
+ commit: number;
64
+ edit: number;
65
+ };
66
+ debt: { shown: number; resolved: number; unknown: number };
67
+ window: string | null;
68
+ }
@@ -0,0 +1,31 @@
1
+ // src/core/hook-helpers.ts — pure helpers shared by hook adapters
2
+ //
3
+ // Extracted from src/hook.ts (PLAN-lib-layer chunk 3). These have zero
4
+ // feature imports and are reused by stop, read-hint, edit-hint, and
5
+ // session-start adapters.
6
+
7
+ import { basename } from "node:path";
8
+
9
+ /** UTC 'YYYY-MM-DD HH:MM:SS' — the format events.ts is written in. */
10
+ export function utcStamp(d: Date): string {
11
+ return d.toISOString().slice(0, 19).replace("T", " ");
12
+ }
13
+
14
+ /**
15
+ * Parse either timestamp shape this repo produces: mem rows are ISO
16
+ * (`new Date().toISOString()`), session start is utcStamp
17
+ * ('YYYY-MM-DD HH:MM:SS', UTC). Never compare them as strings — 'T' > ' '
18
+ * makes any same-date mem row read as "newer than session start".
19
+ */
20
+ export function hookTsMs(ts: string): number {
21
+ const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)
22
+ ? `${ts.replace(" ", "T")}Z`
23
+ : ts;
24
+ return new Date(iso).getTime();
25
+ }
26
+
27
+ /** Filename key for a session — basename of a transcript path or a raw id. */
28
+ export function sessionKey(session: string): string {
29
+ const base = basename(session).replace(/\.[^.]+$/, "");
30
+ return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
31
+ }