fapony 0.3.5 โ†’ 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -434,9 +434,10 @@ years:
434
434
  - **There is no `MASTER.md`.** Every line above is derived from the plan files themselves, so it
435
435
  cannot drift; a hand-kept master file always does.
436
436
 
437
- `status` / `blocked_by` / `blocks` / `superseded_by` are read by people, not by a tool โ€” the one
438
- that read them, `plan_list`, was removed in 2026-09 once `mem kickoff` answered the same
439
- question from the CLI, where a schema costs nothing until it runs.
437
+ `status` / `blocked_by` / `blocks` / `superseded_by` are read by `fapony mem plan-check`
438
+ (dangling refs, blocker shipped but dependent still blocked, waiter cycles, blocked with all
439
+ chunks ticked) and by `fapony mem plan-sweep` (blocked view + a `๐Ÿ”“` unblock hint on `--apply`).
440
+ Sentence values ("waiting on support email") carry no `PLAN-*.md` token and are never flagged.
440
441
 
441
442
  The layout, and why archiving is a plain `git mv`:
442
443
 
@@ -464,13 +465,13 @@ fapony price-scan # fetch model price table โ†’ prices.js
464
465
  fapony usage-web [port] # live usage comparison dashboard from cache
465
466
  fapony stats [--mode verdict [--regime code|fix|review|plan|inquiry|test]] # KPIs: pass/stall rate, by-model, by-grade โ€” --mode verdict ranks by quality/tokens instead
466
467
  fapony digest [--since 7d|YYYY-MM-DD] [--format text|html] [--json] [--out FILE] # single-page summary: decisions, open bugs, in-flight plans, cost, pass/fail โ€” from what's already on disk
467
- fapony plan-seed <name> [--spec] [--scope <path>]... # write PLAN (+SPEC): frontmatter, 8 empty sections, prior-art list, ledger context; SPEC chunks carry signatures, every section capped โ€” the agent fills the judgment
468
+ fapony plan-seed <name> [--spec] [--scope <path>]... # write PLAN (+SPEC): frontmatter, 8 empty sections, prior-art list, mem-decision context + existing-in-scope; existing plans listed on stdout; SPEC chunks carry signatures, every section capped โ€” the agent fills the judgment
468
469
  fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>] # read-only scope facts for a review (changed files, importers, untested, signatures, plan cross-check)
469
470
 
470
471
  # Memory & convention debt
471
472
  fapony mem add <kind> "<text>" --files f1,f2 [spec.md] # append a mem row (decision/bug/note/next/hold)
472
473
  fapony mem close <id> "<msg>" # close a bug
473
- fapony mem find "<text>" # substring-search every row
474
+ fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open] # search mem log
474
475
  fapony mem kickoff [<plan.md>] # open a session + a next-up list
475
476
  fapony mem where # show the resolved mem dir and which step won
476
477
  fapony mem done | stale # views
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick โ€” plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
@@ -21,7 +21,7 @@ You are about to move a PLAN that has been shipped to the archive.
21
21
  Say in the summary that you stamped it, so a wrong HEAD is visible and correctable.
22
22
  STOP only if there's no git repo / no commits to hash from.
23
23
 
24
- 1b. **A plan can also leave `plan/` without shipping** โ€” it got absorbed into another plan, or the
24
+ 1b. **A plan can also leave `plan/` without shipping** โ€” it got absorbed into another plan, or the
25
25
  redesign deleted the thing it planned. That is normal during a UI/UX sweep and is the main
26
26
  reason `plan/` grows forever: there is no state for "dead" so it just sits there. Archive it
27
27
  the same way, with two differences โ€” header `> โ›” **superseded by [PLAN-bar.md](PLAN-bar.md)**
@@ -37,12 +37,22 @@ You are about to move a PLAN that has been shipped to the archive.
37
37
  A plan that is merely *waiting* (on a person, a customer, a decision) is **not** dead and does
38
38
  not move โ€” mark it `status: blocked` + `blocked_by: <what you are waiting for>` and leave it in
39
39
  `plan/` โ€” the frontmatter is for the next person reading the folder, and the plan stays out
40
- of `done/`, which is what `plan-sweep` and `kickoff` go by.
40
+ of `done/`, which is what `plan-sweep` and `kickoff` go by. Never `--apply` a blocked file;
41
+ a blocked file with all chunks ticked is deferred doc debt โ€” ask the user: ship it or keep
42
+ waiting.
43
+
44
+ 1c. **Check the dep graph before moving** โ€” `fapony mem plan-check` reads `blocked_by`/`blocks`
45
+ and says what a human would miss: a `blocked_by` pointing at a file that is not in `plan/`
46
+ or `done/`, a blocker already in `done/` while the dependent is still `status: blocked`,
47
+ a waiter cycle, and a blocked plan with all chunks ticked. Fix its issues first โ€” a move
48
+ on top of a broken graph just relocates the confusion.
41
49
 
42
50
  2. **Run `plan-sweep --apply`** โ€” this does the `git mv`, rewrites markdown links inside the
43
51
  file and inbound links from every `.md` under `.fapony/` (`plan/`, `done/`, `spec/`),
44
52
  warns about plain-text mentions and about tracked files outside `.fapony/` that still
45
- name the file (both detect-only), and logs a decision row โ€” all in one call:
53
+ name the file, prints a `๐Ÿ”“ <shipped> โ€” <waiter> lists it as blocker` line when the ship
54
+ unblocks a waiting plan (copy that line into your summary โ€” the waiter keeps
55
+ `status: blocked` until its owner clears it), and logs a decision row โ€” all in one call:
46
56
  ```bash
47
57
  fapony mem plan-sweep <PLAN-foo.md> --apply
48
58
  ```
@@ -88,9 +88,13 @@ fapony plan-seed <feature> --spec --scope <path>
88
88
  One command, no MCP round trip. It writes `<planDir>/PLAN-<feature>.md` +
89
89
  `<specDir>/SPEC-<feature>.md` โ€” the frontmatter, the 8 empty sections, a `## 8. References` list
90
90
  of shipped plans that already touched this scope, and a `## Context (fapony)` block under the
91
- TL;DR (recent mem decisions plus which model holds up per task shape here). SPEC chunks carry
91
+ TL;DR (recent mem decisions plus what is already in scope โ€” one line per scope file with its
92
+ exports; needs `--scope` to list anything). No ledger-ranking line: the ledger is frozen and
93
+ cross-model ranking claims are off the table, so the seed does not point at them. SPEC chunks carry
92
94
  verbatim signatures, hard-capped (PLAN โ‰ค ~60 / SPEC โ‰ค 200 lines), and capped lines say what was
93
- cut. The CLI resolves plan-dir/spec-dir and refuses to overwrite (pick `-v2` โ€” see Phase 2).
95
+ cut. Stdout ends with the existing plan list (active first, then shipped) โ€” the seed that lands
96
+ next to a shipped decision without knowing it is the expensive mistake, and the file guard in ยง8
97
+ is not where the glance lands. The CLI resolves plan-dir/spec-dir and refuses to overwrite (pick `-v2` โ€” see Phase 2).
94
98
 
95
99
  **ยง2/ยง5 arrive empty on purpose (2026-09-18).** They used to hold a repetition scan and analyze
96
100
  findings; measured over every plan that ever used them, ยง5 printed "no findings" 3 times out of 3
@@ -0,0 +1,50 @@
1
+ // src/adapters/hooks/bug-markers.ts โ€” how fapony recognises "a bug was found".
2
+ //
3
+ // Split from stop.ts so the Stop hook (blocking, claude/cursor/codex) and the
4
+ // OpenCode commit hint (advisory) share one definition. A bug that lives only
5
+ // in a `note` row is not surfaced by anything, so the two signals are:
6
+ //
7
+ // 1. a structured commit-message token โ€” `fix:` / `bugfix:` / `hotfix:`.
8
+ // Universal by construction: only the type token is English, so a Thai
9
+ // description on a conventional commit still reads as a bug
10
+ // (`fix(debt): ...`). This is the language-independent signal, and it is
11
+ // already how this repo commits (120/581 commits are fix-family).
12
+ // 2. free-text announcement phrases โ€” inherently per-language. A substring
13
+ // list cannot be universal; keep it small and open instead. Add your
14
+ // language here โ€” do NOT invent a new row kind for it (open/closed is
15
+ // derived from close rows already; a kind would just drift).
16
+ //
17
+ // Deliberately NOT matched: symptom words ("broken", "dies silently"). They
18
+ // appear in every bug report and would fire on any turn that reads one โ€” the
19
+ // Stop hook blocks once per session on a match, so a false positive is costly.
20
+
21
+ /** Free-text announcement phrases ("I found a bug"), never symptom words. */
22
+ export const BUG_MARKERS: RegExp[] = [
23
+ /เน€เธˆเธญเธšเธฑเนŠเธ/,
24
+ /เธžเธšเธšเธฑเนŠเธ/,
25
+ /\bfound (?:a |the )?bug\b/i,
26
+ /\b(?:this|that|it)(?:'s| is) a bug\b/i,
27
+ /\bbug\b\s*:/i,
28
+ ];
29
+
30
+ /**
31
+ * The matched marker phrase, or null. Returns the matched text so the caller
32
+ * can quote it back to the agent (stop.ts does, in its block reason).
33
+ */
34
+ export function hasBugMarker(text: string): string | null {
35
+ for (const re of BUG_MARKERS) {
36
+ const m = text.match(re);
37
+ if (m) return m[0];
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Conventional-commit bug types, anchored to the subject prefix โ€” arbitrary
43
+ // prose containing "fix" ("pre-fix the cache") must not match. Language-free:
44
+ // the type token stays English even when the description is not.
45
+ const BUGFIX_COMMIT = /^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:/;
46
+
47
+ /** True when a commit subject declares a fix (the universal bug token). */
48
+ export function isBugfixCommit(subject: string): boolean {
49
+ return BUGFIX_COMMIT.test(subject.trim());
50
+ }
@@ -3,15 +3,21 @@
3
3
  // Split from src/hook.ts (PLAN-lib-layer chunk 3). Reads hint log + re-runs
4
4
  // debt detection to count resolved vs unresolved hints.
5
5
 
6
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
- import { join } from "node:path";
6
+ import {
7
+ existsSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ statSync,
12
+ } from "node:fs";
13
+ import { dirname, join } from "node:path";
8
14
  import {
9
15
  type HintFireRow,
10
16
  type HintImpact,
11
17
  hintLogDir,
12
18
  worktreeKey,
13
19
  } from "../../core/hint-log.js";
14
- import { debtForFile, loadConventions } from "../../debt/index.js";
20
+ import { debtForFile, resolveDebtScope } from "../../debt/index.js";
15
21
 
16
22
  /**
17
23
  * Compute hint-fire impact from the log. `since` is an ISO date string;
@@ -80,14 +86,25 @@ export function computeHintImpact(
80
86
 
81
87
  for (const [key, ids] of debtByFile) {
82
88
  const [worktree, file] = key.split("\t");
83
- const absFile = join(worktree, file);
89
+ let absFile = join(worktree, file);
84
90
  let currentIds: Set<string>;
85
91
  try {
86
92
  if (!statSync(absFile).isFile()) {
87
93
  impact.debt.unknown += ids.length;
88
94
  continue;
89
95
  }
90
- const convs = debtForFile(worktree, absFile, loadConventions(worktree));
96
+ // The log stores the worktree lexically; scanRoot is physical โ€”
97
+ // resolve so debtForFile compares like with like.
98
+ try {
99
+ absFile = realpathSync(absFile);
100
+ } catch {
101
+ // keep the lexical form
102
+ }
103
+ // Same scope as the hint itself โ€” resolving at the git root would
104
+ // load zero conventions in a monorepo and count every shown id as
105
+ // resolved (precision stuck at 100%).
106
+ const scope = resolveDebtScope(dirname(absFile));
107
+ const convs = debtForFile(scope.scanRoot, absFile, scope.loaded);
91
108
  currentIds = new Set(convs.map((c) => c.id));
92
109
  } catch {
93
110
  impact.debt.unknown += ids.length;
@@ -4,9 +4,9 @@
4
4
  // edit-hint adapters to attach debt/mem lines when a file is open.
5
5
 
6
6
  import { realpathSync } from "node:fs";
7
- import { basename, join, relative } from "node:path";
7
+ import { basename, dirname, join, relative } from "node:path";
8
8
  import { collectSourceFiles, SCAN_EXTS } from "../../analyze.js";
9
- import { debtForFile, loadConventions } from "../../debt/index.js";
9
+ import { debtForFile, resolveDebtScope } from "../../debt/index.js";
10
10
  import { readMemLog } from "../../memory.js";
11
11
 
12
12
  const DEBT_HINT_MAX = 3;
@@ -44,21 +44,27 @@ export function readContextData(
44
44
  const debtLines: string[] = [];
45
45
  const memLines: string[] = [];
46
46
 
47
- // convention debt โ€” source files only, fresh from the repo
47
+ // convention debt โ€” source files only, fresh from the repo. The scope
48
+ // pairs the git root (repo-relative `where`) with the nearest
49
+ // conventions file โ€” anchoring the load at the root goes silent in a
50
+ // monorepo with app-scoped conventions (bug mucvfaxk).
48
51
  const dot = rel.lastIndexOf(".");
49
52
  if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
50
- for (const c of debtForFile(
51
- worktree,
52
- abs,
53
- loadConventions(worktree),
54
- ).slice(0, DEBT_HINT_MAX)) {
53
+ const scope = resolveDebtScope(dirname(abs));
54
+ for (const c of debtForFile(scope.scanRoot, abs, scope.loaded).slice(
55
+ 0,
56
+ DEBT_HINT_MAX,
57
+ )) {
55
58
  debtIds.push(c.id);
56
59
  debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
57
60
  }
58
61
  }
59
62
 
60
- // mem rows that are about this file
61
- const mem = readMemLog(worktree);
63
+ // mem rows that are about this file โ€” resolve the log from the file's own
64
+ // directory, not the repo root. In a monorepo the log is app-scoped, so
65
+ // anchoring at the root sees only an out-of-scope candidate and goes silent
66
+ // even though the file being touched sits right under its log (bug muc9q47r).
67
+ const mem = readMemLog(dirname(abs));
62
68
  if (mem.rows.length > 0) {
63
69
  const base = basename(rel);
64
70
  const direct: typeof mem.rows = [];
@@ -15,6 +15,11 @@ export {
15
15
  sessionKey,
16
16
  utcStamp,
17
17
  } from "../../core/hook-helpers.js";
18
+ export {
19
+ BUG_MARKERS,
20
+ hasBugMarker,
21
+ isBugfixCommit,
22
+ } from "./bug-markers.js";
18
23
  export { computeHintImpact } from "./compute-hint-impact.js";
19
24
  export {
20
25
  type ContextLineData,
@@ -18,6 +18,7 @@ import { recordHintFire } from "../../core/hint-log.js";
18
18
  import { sessionKey } from "../../core/hook-helpers.js";
19
19
  import { readMemLog } from "../../memory.js";
20
20
  import { renderSeed } from "../../seed/review-seed.js";
21
+ import { hasBugMarker, isBugfixCommit } from "./bug-markers.js";
21
22
  import { readContextData } from "./context-data.js";
22
23
 
23
24
  // --- Read hint (size) ---
@@ -244,7 +245,9 @@ export function commitHintFor(opts: CommitHintInput): string | null {
244
245
 
245
246
  let memLastTs: string | null = null;
246
247
  try {
247
- memLastTs = readMemLog(worktree).rows[0]?.ts ?? null;
248
+ // Anchor at the dir the commit ran in, not the repo root: the log is
249
+ // app-scoped in a monorepo, and root resolution misses it (bug muc9q47r).
250
+ memLastTs = readMemLog(opts.cwd).rows[0]?.ts ?? null;
248
251
  } catch {
249
252
  memLastTs = null;
250
253
  }
@@ -258,6 +261,13 @@ export function commitHintFor(opts: CommitHintInput): string | null {
258
261
  const commitList = log ? log.split("\n").filter(Boolean) : [];
259
262
  if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
260
263
 
264
+ // %h %s โ€” strip the short hash to test the subject alone.
265
+ const subjectOf = (c: string) => c.replace(/^\S+\s+/, "");
266
+ const bugCommits = commitList.filter(
267
+ (c) =>
268
+ isBugfixCommit(subjectOf(c)) || hasBugMarker(subjectOf(c)) !== null,
269
+ );
270
+
261
271
  const lines: string[] = [
262
272
  `${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) โ€” record a mem row for this work.`,
263
273
  ];
@@ -266,6 +276,12 @@ export function commitHintFor(opts: CommitHintInput): string | null {
266
276
  lines.push(
267
277
  `fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
268
278
  );
279
+ if (bugCommits.length > 0) {
280
+ lines.push(
281
+ `${bugCommits.length} of these read as a bug (fix-type commit or found-a-bug wording) โ€” use kind:bug so it surfaces later, not note:`,
282
+ );
283
+ lines.push(` fapony mem add bug "what broke" --files <files>`);
284
+ }
269
285
 
270
286
  const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
271
287
  return prefixed;
@@ -69,12 +69,23 @@ export function sessionStartContext(
69
69
  cwd: string,
70
70
  faponyTs: string = Bun.main,
71
71
  ): string | null {
72
- if (!whereMemDir(cwd).dir) return null;
73
- const p = Bun.spawnSync([process.execPath, faponyTs, "mem", "kickoff"], {
74
- cwd,
75
- stdout: "pipe",
76
- stderr: "pipe",
77
- });
72
+ // Resolution shares one function with every reader. A bare monorepo root has
73
+ // no log at/above cwd, so the resolver reports the app-scoped log only as an
74
+ // out-of-scope candidate (SPEC ยง1). For a *read* at session start that is
75
+ // still recoverable: if the whole repo holds exactly one log, it is the
76
+ // project's memory โ€” point kickoff at it with --mem-dir. Two or more is
77
+ // genuinely ambiguous (which app?), so refuse exactly as the resolver does.
78
+ const resolved = whereMemDir(cwd);
79
+ const memArgs: string[] = [];
80
+ if (!resolved.dir) {
81
+ const candidates = resolved.candidates ?? [];
82
+ if (candidates.length !== 1) return null;
83
+ memArgs.push("--mem-dir", candidates[0]);
84
+ }
85
+ const p = Bun.spawnSync(
86
+ [process.execPath, faponyTs, "mem", ...memArgs, "kickoff"],
87
+ { cwd, stdout: "pipe", stderr: "pipe" },
88
+ );
78
89
  const out = p.stdout.toString().trim();
79
90
  if (p.exitCode !== 0 || !out) return null;
80
91
  return capContext(out);
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { hookTsMs, sessionKey, utcStamp } from "../../core/hook-helpers.js";
17
17
  import { readMemLog, whereMemDir } from "../../memory.js";
18
+ import { hasBugMarker } from "./bug-markers.js";
18
19
 
19
20
  // --- Types ---
20
21
 
@@ -252,9 +253,6 @@ export function stopBlockedBefore(
252
253
 
253
254
  // --- Bug-signal detection ---
254
255
 
255
- /** Announcement words that indicate the agent found a bug โ€” not symptom words. */
256
- const BUG_MARKERS: RegExp[] = [/เน€เธˆเธญเธšเธฑเนŠเธ/];
257
-
258
256
  /**
259
257
  * Scan assistant text from a Claude transcript for bug markers. Reads only
260
258
  * the tail of the file (last 200KB) to avoid parsing the full transcript.
@@ -314,12 +312,8 @@ export function bugSignalFromTranscript(
314
312
  }
315
313
  for (const b of m.content) {
316
314
  if (b.type !== "text" || typeof b.text !== "string") continue;
317
- for (const re of BUG_MARKERS) {
318
- if (re.test(b.text)) {
319
- const match = b.text.match(re);
320
- return match?.[0] ?? null;
321
- }
322
- }
315
+ const hit = hasBugMarker(b.text);
316
+ if (hit) return hit;
323
317
  }
324
318
  }
325
319
  } catch {
@@ -3,7 +3,7 @@
3
3
  // Every schema here is paid as input tokens in *every* session of *every*
4
4
  // client that connects, whether or not the tool is called. Adding one is
5
5
  // buying attention with a standing charge; earning it back means the tool
6
- // saves more than it costs (see CLAUDE.md "เธˆเนˆเธฒเธข token เธญเธขเนˆเธฒเธ‡เธ‰เธฅเธฒเธ”"). Keep
6
+ // saves more than it costs (see CLAUDE.md "Spend tokens smart"). Keep
7
7
  // descriptions imperative โ€” say what to send, not why it matters.
8
8
 
9
9
  export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
@@ -59,6 +59,11 @@ export const TOOLS = [
59
59
  description:
60
60
  "Max rows returned (default 20) โ€” total still counts all matches",
61
61
  },
62
+ open: {
63
+ type: "boolean",
64
+ description:
65
+ "true = unresolved work only: drops close/claim/release/synced rows and work rows already closed. Omit = every row. With kind:[bug] answers 'what bugs remain?'",
66
+ },
62
67
  },
63
68
  required: ["worktree"],
64
69
  },
@@ -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
+ }