fapony 0.3.5 → 0.4.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.
@@ -6,15 +6,14 @@
6
6
  // file is unfindable when you touch that file (fill rate 26% CLI-flag vs 88%
7
7
  // MCP-required — schema wins over prose every time, see CLAUDE.md rule 9).
8
8
 
9
- import { openRows } from "../../../mem/selectors.js";
10
9
  import {
11
- initStore,
12
- KINDS,
13
- nextId,
14
- put,
15
- rows,
16
- type WorkKind,
17
- } from "../../../mem/store.js";
10
+ type EngineAddResult,
11
+ type EngineCloseResult,
12
+ engineAdd,
13
+ engineClose,
14
+ engineFind,
15
+ } from "../../../mem/engine.js";
16
+ import { initStore, KINDS, type WorkKind } from "../../../mem/store.js";
18
17
  import { type MemRow, readMemLog, resolveMemDir } from "../../../memory.js";
19
18
  import { errorResult, jsonResult, type ToolResult } from "../types.js";
20
19
 
@@ -33,37 +32,24 @@ export function memFind(args: {
33
32
  kind?: string[];
34
33
  since?: string;
35
34
  limit?: number;
35
+ open?: boolean;
36
36
  }): MemFindResult {
37
- const read = readMemLog(args.worktree, args.since);
38
-
39
- let rows = read.rows;
40
- if (args.kind && args.kind.length > 0) {
41
- const kinds = new Set(args.kind);
42
- rows = rows.filter((r) => kinds.has(r.kind));
43
- }
44
- if (args.text) {
45
- const needle = args.text.toLowerCase();
46
- rows = rows.filter((r) => r.text.toLowerCase().includes(needle));
47
- }
48
- if (args.files && args.files.length > 0) {
49
- // Rows written by `mem add --files` carry files[] — match that first.
50
- // Older rows (and any row whose author skipped --files) have none, so the
51
- // text/spec/ref substring stays as the fallback: low recall by nature,
52
- // a limit of the data rather than of the query (spec §5.4).
53
- const paths = args.files.map((f) => f.toLowerCase());
54
- rows = rows.filter((r) => {
55
- const stored = (r.files ?? []).map((f) => f.toLowerCase());
56
- if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
57
- return true;
58
- const hay = `${r.text}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
59
- return paths.some((p) => hay.includes(p));
60
- });
61
- }
62
-
63
- const total = rows.length;
64
- const limit = Math.max(0, args.limit ?? 20);
37
+ // Query logic lives in the shared engine (src/mem/engine.ts) — this wrapper
38
+ // owns only the read (readMemLog sees live + rotated archives via its loose
39
+ // log*.jsonl regex) and the result shape. No kind default here by contract:
40
+ // omitting kind returns every kind (locked by test). CLI cmdFind calls the
41
+ // same engine with its own bookkeeping exclude.
42
+ const read = readMemLog(args.worktree);
43
+ const { rows: matched, total } = engineFind(read.rows, {
44
+ text: args.text,
45
+ files: args.files,
46
+ kind: args.kind,
47
+ sinceIso: args.since,
48
+ limit: args.limit,
49
+ open: args.open,
50
+ });
65
51
  return {
66
- rows: rows.slice(0, limit),
52
+ rows: matched,
67
53
  total,
68
54
  filesFound: read.filesFound,
69
55
  skipped: read.skipped,
@@ -100,24 +86,25 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
100
86
  : undefined;
101
87
  const text = typeof args.text === "string" ? args.text : undefined;
102
88
  const since = typeof args.since === "string" ? args.since : undefined;
89
+ if (since !== undefined && Number.isNaN(Date.parse(since))) {
90
+ return errorResult(
91
+ `since must be an ISO date (e.g. 2026-09-19T00:00:00.000Z), got: "${since}" — CLI accepts <N>d/YYYY-MM-DD, MCP takes ISO only`,
92
+ );
93
+ }
103
94
  const limit = typeof args.limit === "number" ? args.limit : undefined;
95
+ const open = typeof args.open === "boolean" ? args.open : undefined;
104
96
 
105
- return jsonResult(memFind({ worktree, files, text, kind, since, limit }));
97
+ return jsonResult(
98
+ memFind({ worktree, files, text, kind, since, limit, open }),
99
+ );
106
100
  }
107
101
 
108
102
  // --- mem_add ---
103
+ //
104
+ // Domain rules live in the shared engine (src/mem/engine.ts) — this wrapper
105
+ // owns only store init for its worktree. CLI cmdAdd calls the same engine.
109
106
 
110
- export interface MemAddResult {
111
- id: string;
112
- kind: string;
113
- text: string;
114
- files: string[];
115
- spec?: string;
116
- ts: string;
117
- }
118
-
119
- const CAP_NEXT = 15;
120
- const CAP_HOLD = 10;
107
+ export type MemAddResult = EngineAddResult;
121
108
 
122
109
  export function memAdd(args: {
123
110
  worktree: string;
@@ -126,62 +113,13 @@ export function memAdd(args: {
126
113
  files: string[];
127
114
  spec?: string;
128
115
  }): MemAddResult {
129
- if (!KINDS.includes(args.kind as WorkKind)) {
130
- throw new Error(
131
- `kind must be one of ${KINDS.join("|")} — got "${args.kind}"`,
132
- );
133
- }
134
- if (args.files.length === 0) {
135
- throw new Error("files must contain at least one path");
136
- }
137
- if (!args.text.trim()) {
138
- throw new Error("text is required and must not be empty");
139
- }
140
- // CLI cmdAdd rejects hold without a spec (write.ts) — keep the two writers
141
- // in lockstep: without a spec a hold can never be resolved by rotate.
142
- if (args.kind === "hold" && !args.spec) {
143
- throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
144
- }
145
-
146
116
  initStore(args.worktree);
147
- const all = rows();
148
-
149
- // Cap check — matches CLI cmdAdd behaviour
150
- if (args.kind === "next" && !process.env.MEM_FORCE) {
151
- const openNext = openRows(all).filter((r) => r.kind === "next").length;
152
- if (openNext >= CAP_NEXT) {
153
- throw new Error(
154
- `open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
155
- );
156
- }
157
- }
158
- if (args.kind === "hold" && !process.env.MEM_FORCE) {
159
- const openHold = openRows(all).filter((r) => r.kind === "hold").length;
160
- if (openHold >= CAP_HOLD) {
161
- throw new Error(
162
- `open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
163
- );
164
- }
165
- }
166
-
167
- const id = nextId(all);
168
- const ts = new Date().toISOString();
169
- put({
170
- id,
171
- kind: args.kind as WorkKind,
172
- text: args.text,
173
- spec: args.spec,
174
- files: args.files,
175
- });
176
-
177
- return {
178
- id,
117
+ return engineAdd({
179
118
  kind: args.kind,
180
119
  text: args.text,
181
120
  files: args.files,
182
121
  spec: args.spec,
183
- ts,
184
- };
122
+ });
185
123
  }
186
124
 
187
125
  export function toolMemAdd(args: Record<string, unknown>): ToolResult {
@@ -246,34 +184,15 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
246
184
  // (commands/write.ts cmdClose): the id must exist; the tombstone voids the
247
185
  // claim by itself.
248
186
 
249
- export interface MemCloseResult {
250
- ref: string;
251
- text: string;
252
- ts: string;
253
- }
187
+ export type MemCloseResult = EngineCloseResult;
254
188
 
255
189
  export function memClose(args: {
256
190
  worktree: string;
257
191
  id: string;
258
192
  text: string;
259
193
  }): MemCloseResult {
260
- if (!args.id.trim()) {
261
- throw new Error("id is required");
262
- }
263
- if (!args.text.trim()) {
264
- throw new Error("text is required and must not be empty");
265
- }
266
-
267
194
  initStore(args.worktree);
268
- const all = rows();
269
- if (!all.some((r) => "id" in r && r.id === args.id)) {
270
- throw new Error(`no id "${args.id}" in the log`);
271
- }
272
-
273
- const ts = new Date().toISOString();
274
- put({ kind: "close", ref: args.id, text: args.text });
275
-
276
- return { ref: args.id, text: args.text, ts };
195
+ return engineClose({ id: args.id, text: args.text });
277
196
  }
278
197
 
279
198
  export function toolMemClose(args: Record<string, unknown>): ToolResult {
@@ -77,7 +77,7 @@ export function formatDebt(report: DebtReport, showAll = false): string {
77
77
  for (let i = 0; i < cap; i++) {
78
78
  const [zone, zoneFiles] = zoneEntries[i];
79
79
  const pad = " ".repeat(Math.max(0, 42 - zone.length));
80
- lines.push(`\n ${zone}${pad}${zoneFiles.length} ไฟล์`);
80
+ lines.push(`\n ${zone}${pad}${zoneFiles.length} files`);
81
81
  lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
82
82
  totalCapped += zoneFiles.length;
83
83
  }
@@ -85,7 +85,7 @@ export function formatDebt(report: DebtReport, showAll = false): string {
85
85
  const remaining = e.files.length - totalCapped;
86
86
  const remainingZones = zoneEntries.length - cap;
87
87
  lines.push(
88
- `\n … อีก ${remainingZones} โซน (${remaining} ไฟล์) — fapony debt --id ${e.conv.id} --all`,
88
+ `\n … ${remainingZones} more zones (${remaining} files) — fapony debt --id ${e.conv.id} --all`,
89
89
  );
90
90
  }
91
91
  }
@@ -0,0 +1,26 @@
1
+ // src/core/since.ts — parse `--since` (Nd | YYYY-MM-DD) into an ISO timestamp.
2
+ //
3
+ // Pure date math, no imports. Two callers share it: digest/collect.ts and
4
+ // mem find (CLI --since + MCP sinceIso passthrough). It lives in core because
5
+ // both are features and core never imports back up (PLAN-unify-mem-engine
6
+ // chunk 2 — digest/collect.ts used to own this, but importing it from mem
7
+ // would drag db/store + hook.js into the mem load path).
8
+
9
+ export function parseSince(
10
+ raw: string | undefined,
11
+ now?: number,
12
+ ): { iso: string; label: string } {
13
+ const def = "7d";
14
+ const s = raw ?? def;
15
+ const dMatch = /^(\d+)d$/.exec(s);
16
+ if (dMatch) {
17
+ const days = Number(dMatch[1]);
18
+ const dt = new Date((now ?? Date.now()) - days * 86400000);
19
+ return { iso: dt.toISOString(), label: `${days}d` };
20
+ }
21
+ const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(s);
22
+ if (dateMatch) {
23
+ return { iso: `${s}T00:00:00.000Z`, label: s };
24
+ }
25
+ throw new Error(`invalid --since format: "${s}" — use <N>d or YYYY-MM-DD`);
26
+ }
package/src/debt/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { CONVENTIONS_FILE } from "../core/config.js";
8
8
  import { formatDebt } from "./format.js";
9
- import { loadConventions } from "./load.js";
9
+ import { resolveDebtScope } from "./load.js";
10
10
  import { findPromotions, formatPromotions } from "./promotion.js";
11
11
  import { debtForFile, debtScan } from "./scan.js";
12
12
  import { ZONE_CAP } from "./types.js";
@@ -119,12 +119,26 @@ export function cmdDebt(args: string[]): void {
119
119
  }
120
120
  }
121
121
 
122
- const worktree = worktreeOf(path);
123
- const loaded = loadConventions(worktree);
122
+ // Scan scope: the git root (convention `where` values are repo-relative)
123
+ // paired with the nearest conventions file — scanning from the app dir
124
+ // dropped repo-relative conventions and hid files outside the app
125
+ // (bug mucvfiv5). Evidence (mem/ledger) keeps the old neighborhood scope.
126
+ // Physical path (symlinks resolved) so --files resolution below compares
127
+ // like with like against scope.scanRoot (/var → /private/var on macOS).
128
+ let base = resolve(path ?? ".");
129
+ try {
130
+ base = realpathSync(base);
131
+ } catch {
132
+ // nonexistent — the not-found notes below handle it
133
+ }
134
+ const scope = resolveDebtScope(base);
135
+ const worktree = scope.scanRoot;
136
+ const loaded = scope.loaded;
137
+ const evidenceDir = worktreeOf(path);
124
138
 
125
139
  if (filesMode) {
126
140
  const out = filesMode.map((f) => {
127
- const abs = isAbsolute(f) ? f : resolve(worktree, f);
141
+ const abs = isAbsolute(f) ? f : resolve(base, f);
128
142
  if (!existsSync(abs) || !statSync(abs).isFile()) {
129
143
  return { file: f, debt: [], note: "not found" as const };
130
144
  }
@@ -178,7 +192,7 @@ export function cmdDebt(args: string[]): void {
178
192
  if (json) {
179
193
  console.log(
180
194
  JSON.stringify(
181
- { ...report, promotions: findPromotions(worktree, report) },
195
+ { ...report, promotions: findPromotions(evidenceDir, report) },
182
196
  null,
183
197
  2,
184
198
  ),
@@ -187,7 +201,7 @@ export function cmdDebt(args: string[]): void {
187
201
  }
188
202
  console.log(formatDebt(report, showAll));
189
203
  for (const w of loaded.warnings) console.log(`⚠ ${w}`);
190
- for (const l of formatPromotions(findPromotions(worktree, report))) {
204
+ for (const l of formatPromotions(findPromotions(evidenceDir, report))) {
191
205
  console.log(l);
192
206
  }
193
207
  }
package/src/debt/load.ts CHANGED
@@ -4,8 +4,14 @@
4
4
  // (<repo>/.fapony/conventions.json — via the same resolver as the mem log).
5
5
  // Missing file = empty + no error.
6
6
 
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
7
+ import {
8
+ existsSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
9
15
  import {
10
16
  CONVENTIONS_FILE,
11
17
  CONVENTIONS_FILENAME,
@@ -28,6 +34,134 @@ export function resolveConventionsPath(worktree: string): string | null {
28
34
  return existsSync(root) ? root : null;
29
35
  }
30
36
 
37
+ export interface DebtScope {
38
+ /**
39
+ * The dir `where` clauses and debt rel-paths are measured against — the git
40
+ * root inside a repo (convention `where` values are repo-relative), the
41
+ * resolved start dir outside one.
42
+ */
43
+ scanRoot: string;
44
+ /** Conventions in scope: nearest file walking up, else the lone file under the root. */
45
+ loaded: LoadedConventions;
46
+ }
47
+
48
+ // Dirs that never hold a conventions.json and are expensive to walk — same
49
+ // prune set as the mem-log ambiguity scan (src/core/mem-log.ts).
50
+ const SCOPE_SKIP = new Set([
51
+ "node_modules",
52
+ ".git",
53
+ "dist",
54
+ "build",
55
+ "coverage",
56
+ "vendor",
57
+ "out",
58
+ "target",
59
+ ".next",
60
+ ".turbo",
61
+ ".cache",
62
+ ]);
63
+
64
+ /** Every `.fapony/conventions.json` under root — bounded walk, pruned dirs skipped. */
65
+ function findConventionsUnder(root: string): string[] {
66
+ const found: string[] = [];
67
+ const walk = (dir: string, depth: number): void => {
68
+ if (depth > 6) return;
69
+ let entries: import("node:fs").Dirent[];
70
+ try {
71
+ entries = readdirSync(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const e of entries) {
76
+ if (!e.isDirectory()) continue;
77
+ if (e.name === FAPONY_DIR) {
78
+ const f = join(dir, FAPONY_DIR, CONVENTIONS_FILENAME);
79
+ if (existsSync(f)) found.push(f);
80
+ continue;
81
+ }
82
+ if (e.name.startsWith(".") || SCOPE_SKIP.has(e.name)) continue;
83
+ walk(join(dir, e.name), depth + 1);
84
+ }
85
+ };
86
+ walk(root, 0);
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * The single debt-scope resolver — hint (`readContextData`), the precision
92
+ * instrument (`computeHintImpact`), and `fapony debt` all pair the same
93
+ * scan root with the same conventions (bugs mucvfaxk + mucvfiv5).
94
+ *
95
+ * Each caller previously resolved differently: the hint and the instrument
96
+ * anchored `loadConventions` at the git root (silent whenever conventions are
97
+ * app-scoped), while the CLI scanned from the app dir (so repo-relative
98
+ * `where` values never matched and files outside the app were invisible).
99
+ * The scan root here is always the git root — `where` is repo-relative —
100
+ * and the conventions are the nearest file walking up from `fromDir`
101
+ * (the app case); when the walk misses — a file outside any app dir, or the
102
+ * repo root itself — the lone `conventions.json` under the root wins.
103
+ * Two or more files under the root stay ambiguous (null, don't guess).
104
+ */
105
+ export function resolveDebtScope(fromDir: string): DebtScope {
106
+ let start = resolve(fromDir);
107
+ try {
108
+ if (!statSync(start).isDirectory()) start = dirname(start);
109
+ } catch {
110
+ // nonexistent path — keep the lexical form, resolution below still applies
111
+ }
112
+ try {
113
+ // Physical path so the walk, the boundary check, and scanRoot compare
114
+ // like with like (/var → /private/var on macOS).
115
+ start = realpathSync(start);
116
+ } catch {
117
+ // vanished mid-call — keep the lexical form
118
+ }
119
+ // `git rev-parse` returns a physical path (/var → /private/var on macOS),
120
+ // so the boundary check compares realpaths, same as worktreeOf in cli.ts.
121
+ const physical = (d: string): string => {
122
+ try {
123
+ return realpathSync(d);
124
+ } catch {
125
+ return d;
126
+ }
127
+ };
128
+ let gitRoot: string | null = null;
129
+ try {
130
+ const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
131
+ cwd: start,
132
+ stdout: "pipe",
133
+ stderr: "pipe",
134
+ });
135
+ if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
136
+ } catch {
137
+ // not a repo — start is all we have
138
+ }
139
+ const scanRoot = gitRoot ? physical(gitRoot) : start;
140
+ const boundary = gitRoot ? physical(gitRoot) : null;
141
+
142
+ // Nearest conventions.json walking up (the app case) — bounded by the root.
143
+ let dir = start;
144
+ while (true) {
145
+ if (existsSync(join(dir, CONVENTIONS_FILE))) {
146
+ return { scanRoot, loaded: loadConventions(dir) };
147
+ }
148
+ if (boundary && physical(dir) === boundary) break;
149
+ const parent = dirname(dir);
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+
154
+ // Sibling-app case: the file sits outside any app dir (e.g. packages/*),
155
+ // but the repo holds exactly one conventions file — use it.
156
+ if (gitRoot) {
157
+ const found = findConventionsUnder(scanRoot);
158
+ if (found.length === 1) {
159
+ return { scanRoot, loaded: loadConventions(dirname(dirname(found[0]))) };
160
+ }
161
+ }
162
+ return { scanRoot, loaded: loadConventions(scanRoot) };
163
+ }
164
+
31
165
  function asString(v: unknown): string | undefined {
32
166
  return typeof v === "string" && v.length > 0 ? v : undefined;
33
167
  }
@@ -14,6 +14,7 @@ import {
14
14
  type Run,
15
15
  } from "../core/config.js";
16
16
  import { type HintImpact, hintLogPath } from "../core/hint-log.js";
17
+ import { parseSince } from "../core/since.js";
17
18
  import { openDb } from "../db/store.js";
18
19
  import { computeHintImpact } from "../hook.js";
19
20
  import { type MemRow, readMemLog } from "../memory.js";
@@ -100,25 +101,6 @@ export interface CollectOpts {
100
101
 
101
102
  // --- helpers ---
102
103
 
103
- function parseSince(
104
- raw: string | undefined,
105
- now?: number,
106
- ): { iso: string; label: string } {
107
- const def = "7d";
108
- const s = raw ?? def;
109
- const dMatch = /^(\d+)d$/.exec(s);
110
- if (dMatch) {
111
- const days = Number(dMatch[1]);
112
- const dt = new Date((now ?? Date.now()) - days * 86400000);
113
- return { iso: dt.toISOString(), label: `${days}d` };
114
- }
115
- const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(s);
116
- if (dateMatch) {
117
- return { iso: `${s}T00:00:00.000Z`, label: s };
118
- }
119
- throw new Error(`invalid --since format: "${s}" — use <N>d or YYYY-MM-DD`);
120
- }
121
-
122
104
  function resolveWorktree(override?: string): string {
123
105
  if (override) return override;
124
106
  try {
package/src/hook.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // from this path.
7
7
 
8
8
  export {
9
+ BUG_MARKERS,
9
10
  bugSignalFromTranscript,
10
11
  COMMIT_HINT_MIN_COMMITS,
11
12
  type CommitHintInput,
@@ -36,9 +37,11 @@ export {
36
37
  gitAutonomyStatus,
37
38
  type HintFireRow,
38
39
  type HintImpact,
40
+ hasBugMarker,
39
41
  hintLogDir,
40
42
  hintLogPath,
41
43
  hookTsMs,
44
+ isBugfixCommit,
42
45
  isCodexPayload,
43
46
  isCursorPayload,
44
47
  mvGuardDecision,