fapony 0.6.2 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
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)",
@@ -3,11 +3,19 @@
3
3
  // Split from src/hook.ts (PLAN-lib-layer chunk 3). Used by both read-hint and
4
4
  // edit-hint adapters to attach debt/mem lines when a file is open.
5
5
 
6
- import { realpathSync } from "node:fs";
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ } from "node:fs";
7
13
  import { basename, dirname, join, relative } from "node:path";
8
14
  import { collectSourceFiles, SCAN_EXTS } from "../../analyze/index.js";
9
- import { MEM_TEXT_MAX, readMemLog } from "../../core/mem-log.js";
15
+ import { readTrackDir, sessionKey } from "../../core/hook-helpers.js";
16
+ import { clipMemText, readMemLog } from "../../core/mem-log.js";
10
17
  import { debtForFile, resolveDebtScope } from "../../debt/index.js";
18
+ import { movedTargets } from "../../mem/selectors.js";
11
19
 
12
20
  const DEBT_HINT_MAX = 3;
13
21
  const MEM_HINT_MAX = 2;
@@ -23,10 +31,40 @@ export interface ContextLineData {
23
31
  memIds: string[];
24
32
  }
25
33
 
26
- /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
34
+ /** Mem row ids already shown this session — a row repeats on every read/edit otherwise. */
35
+ function shownPath(session: string): string {
36
+ return join(readTrackDir(), `${sessionKey(session)}.mem-shown`);
37
+ }
38
+
39
+ function readShown(session: string): Set<string> {
40
+ try {
41
+ const p = shownPath(session);
42
+ if (!existsSync(p)) return new Set();
43
+ return new Set(readFileSync(p, "utf-8").split("\n").filter(Boolean));
44
+ } catch {
45
+ return new Set();
46
+ }
47
+ }
48
+
49
+ function markShown(session: string, ids: string[]): void {
50
+ if (ids.length === 0) return;
51
+ try {
52
+ mkdirSync(readTrackDir(), { recursive: true });
53
+ appendFileSync(shownPath(session), `${ids.join("\n")}\n`, "utf-8");
54
+ } catch {
55
+ // a hint must never fail the tool call
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Structured data behind readContextLines — used by cmdHookReadHint for logging.
61
+ * With a session, a non-bug mem row shows once per session (open bugs always
62
+ * show: they are exact files[] matches and the point is to nag until closed).
63
+ */
27
64
  export function readContextData(
28
65
  filePath: unknown,
29
66
  cwd: string,
67
+ session?: unknown,
30
68
  ): ContextLineData | null {
31
69
  try {
32
70
  if (typeof filePath !== "string" || filePath === "") return null;
@@ -71,6 +109,26 @@ export function readContextData(
71
109
  // even though the file being touched sits right under its log (bug muc9q47r).
72
110
  const mem = readMemLog(dirname(abs));
73
111
  if (mem.rows.length > 0) {
112
+ // PLAN-mem-core chunk 5 — follow moves: a row naming old/a.ts still
113
+ // shows when the file now lives at new/a.ts, as long as no other a.ts
114
+ // exists on disk. Missing = stat-miss on the worktree-joined path; the
115
+ // tree walk runs only when some row actually names a missing file.
116
+ const missingOld = new Set<string>();
117
+ for (const r of mem.rows)
118
+ for (const f of r.files ?? [])
119
+ if (f !== rel && !existsSync(join(worktree, f))) missingOld.add(f);
120
+ const moved =
121
+ missingOld.size > 0
122
+ ? movedTargets([...missingOld], collectSourceFiles(worktree))
123
+ : null;
124
+ const filesOf = (r: (typeof mem.rows)[number]): string[] =>
125
+ (r.files ?? []).map((f) => moved?.get(f) ?? f);
126
+ const movedFrom = (r: (typeof mem.rows)[number]): string => {
127
+ const from = (r.files ?? []).find(
128
+ (f) => f !== rel && moved?.get(f) === rel,
129
+ );
130
+ return from ? ` (moved from ${from})` : "";
131
+ };
74
132
  // Open bugs first (PLAN-active-pain chunk 3): a kind:"bug" row whose id
75
133
  // is in no close row's ref — the same tombstone shape as openRows in
76
134
  // src/mem/selectors.ts, never a regex on text. Exact files[] match only:
@@ -82,23 +140,29 @@ export function readContextData(
82
140
  for (const r of mem.rows) {
83
141
  if (openLines.length >= MEM_HINT_MAX) break;
84
142
  if (r.kind !== "bug" || !r.id || closed.has(r.id)) continue;
85
- if (!(r.files ?? []).includes(rel)) continue;
143
+ if (!filesOf(r).includes(rel)) continue;
86
144
  openLines.push(
87
- `fapony mem: OPEN BUG ${r.id} — "${r.text.slice(0, MEM_TEXT_MAX)}" — ปิดด้วย fapony mem close ${r.id} "<msg>" เมื่อแก้แล้ว`,
145
+ `fapony mem: OPEN BUG ${r.id} — "${clipMemText(r.text)}" — ปิดด้วย fapony mem close ${r.id} "<msg>" เมื่อแก้แล้ว${movedFrom(r)}`,
88
146
  );
89
147
  openBugIds.push(r.id);
90
148
  }
91
149
  const base = basename(rel);
92
- const direct: typeof mem.rows = [];
150
+ // Tiers: files[] names this file > text/spec mentions the path >
151
+ // basename-only. readMemLog is newest first, so each tier is too.
152
+ const exact: typeof mem.rows = [];
153
+ const mention: typeof mem.rows = [];
93
154
  const baseOnly: typeof mem.rows = [];
94
155
  for (const r of mem.rows) {
95
- if (r.kind === "claim" || r.kind === "release") continue;
96
- const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
97
- if ((r.files ?? []).includes(rel) || hay.includes(rel)) {
98
- direct.push(r);
156
+ if (r.kind === "claim" || r.kind === "release" || r.kind === "close") {
157
+ continue;
158
+ }
159
+ if (filesOf(r).includes(rel)) {
160
+ exact.push(r);
99
161
  continue;
100
162
  }
101
- if (base && hay.includes(base)) baseOnly.push(r);
163
+ const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
164
+ if (hay.includes(rel)) mention.push(r);
165
+ else if (base && hay.includes(base)) baseOnly.push(r);
102
166
  }
103
167
  let usableBase = baseOnly;
104
168
  if (baseOnly.length > 0) {
@@ -107,21 +171,29 @@ export function readContextData(
107
171
  ).length;
108
172
  if (sameName !== 1) usableBase = [];
109
173
  }
174
+ const shown =
175
+ typeof session === "string" && session !== ""
176
+ ? readShown(session)
177
+ : null;
110
178
  // Open lines take the closed rows' slots first; the rest fills the
111
179
  // remaining budget without adding lines past the cap — and without
112
- // repeating a row already emitted as OPEN BUG.
180
+ // repeating a row already emitted as OPEN BUG or shown this session.
113
181
  const emitted = new Set(openBugIds);
114
- const rest = [...direct, ...usableBase]
115
- .filter((r) => !(r.id && emitted.has(r.id)))
182
+ const rest = [exact, mention, usableBase]
183
+ .flat()
184
+ .filter((r) => !(r.id && (emitted.has(r.id) || shown?.has(r.id))))
116
185
  .slice(0, MEM_HINT_MAX - openLines.length);
117
186
  for (const line of openLines) memLines.push(line);
118
187
  memIds.push(...openBugIds);
188
+ const restIds: string[] = [];
119
189
  for (const r of rest) {
120
- if (r.id) memIds.push(r.id);
190
+ if (r.id) restIds.push(r.id);
121
191
  memLines.push(
122
- `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
192
+ `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${clipMemText(r.text)}${movedFrom(r)}`,
123
193
  );
124
194
  }
195
+ memIds.push(...restIds);
196
+ if (shown && typeof session === "string") markShown(session, restIds);
125
197
  }
126
198
  return { worktree, debtIds, debtLines, memLines, openBugIds, memIds };
127
199
  } catch {
@@ -133,7 +133,7 @@ export async function cmdHookEditHint(): Promise<void> {
133
133
  const parts: string[] = [];
134
134
  const hint = editHintFor({ filePath, cwd, session });
135
135
  if (hint) parts.push(hint);
136
- const ctx = readContextData(filePath, cwd);
136
+ const ctx = readContextData(filePath, cwd, session);
137
137
  if (ctx) {
138
138
  for (const line of [...ctx.debtLines, ...ctx.memLines]) {
139
139
  parts.push(line);
@@ -11,11 +11,10 @@ import {
11
11
  realpathSync,
12
12
  statSync,
13
13
  } from "node:fs";
14
- import { homedir } from "node:os";
15
14
  import { join, relative, resolve } from "node:path";
16
15
  import { SCAN_EXTS } from "../../analyze/index.js";
17
16
  import { recordHintFire } from "../../core/hint-log.js";
18
- import { sessionKey } from "../../core/hook-helpers.js";
17
+ import { readTrackDir, sessionKey } from "../../core/hook-helpers.js";
19
18
  import { readMemLog } from "../../memory.js";
20
19
  import { renderSeed } from "../../seed/review-seed.js";
21
20
  import { hasBugMarker, isBugfixCommit } from "./bug-markers.js";
@@ -105,21 +104,12 @@ export function readHintFor(opts: ReadHintInput): string | null {
105
104
 
106
105
  // --- Re-read tracking (mtime heuristic) ---
107
106
 
108
- const READ_TRACK_DIR = "read-track";
109
-
110
107
  export interface ReadTrackRow {
111
108
  ts: string;
112
109
  path: string;
113
110
  mtime: number;
114
111
  }
115
112
 
116
- /** Directory holding one read log per session. */
117
- function readTrackDir(): string {
118
- const base =
119
- process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
120
- return join(base, READ_TRACK_DIR);
121
- }
122
-
123
113
  /** Absolute path of a session's read log — may not exist. */
124
114
  export function readTrackPath(session: string): string {
125
115
  return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
@@ -324,7 +314,7 @@ export async function cmdHookReadHint(): Promise<void> {
324
314
  session,
325
315
  });
326
316
  if (reread) parts.push(reread);
327
- const ctx = readContextData(filePath, cwd);
317
+ const ctx = readContextData(filePath, cwd, session);
328
318
  if (ctx) {
329
319
  for (const line of [...ctx.debtLines, ...ctx.memLines]) {
330
320
  parts.push(line);
@@ -112,9 +112,9 @@ export const TOOLS = [
112
112
  },
113
113
  key: {
114
114
  type: "string",
115
- pattern: "^[a-z0-9-]{3,40}$",
115
+ pattern: "^[a-z0-9-]{3,40}(:[a-z0-9-]{1,40})?$",
116
116
  description:
117
- "Problem identity (not kind, not files) — same problem, same key",
117
+ "Problem identity (not kind, not files) — same problem, same key; domain:sub (e.g. auth:login) when the repo declares domains",
118
118
  },
119
119
  },
120
120
  required: ["worktree", "kind", "text", "files"],
@@ -13,6 +13,7 @@ import {
13
13
  engineClose,
14
14
  engineFind,
15
15
  } from "../../../mem/engine.js";
16
+ import { loadKeyRegistry } from "../../../mem/key-registry.js";
16
17
  import { initStore, KINDS, type WorkKind } from "../../../mem/store.js";
17
18
  import { type MemRow, readMemLog, resolveMemDir } from "../../../memory.js";
18
19
  import { errorResult, jsonResult, type ToolResult } from "../types.js";
@@ -101,9 +102,10 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
101
102
  // Shape gate like mem_add — a non-string key must not coerce to undefined
102
103
  // (silent drop = reject-without-saying). Pattern is NOT checked here: find
103
104
  // answers a wrong-pattern key with knownKeys, not a reject (SPEC fail example).
105
+ // A bare domain doubles as a prefix — key:"auth" catches every auth:*.
104
106
  if (args.key !== undefined && typeof args.key !== "string") {
105
107
  return errorResult(
106
- 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
108
+ 'key must be a string matching [a-z0-9-]{3,40}(:[a-z0-9-]{1,40})? — e.g. "fix-stop-dedupe" or "auth:login"',
107
109
  );
108
110
  }
109
111
  const key = typeof args.key === "string" ? args.key : undefined;
@@ -129,12 +131,16 @@ export function memAdd(args: {
129
131
  key?: string;
130
132
  }): MemAddResult {
131
133
  initStore(args.worktree);
134
+ // Registry beside the mem log; absent = free-form keys (never reject).
135
+ // Loaded per call, not at MCP start (initialize budget ≤ ~100ms).
136
+ const reg = loadKeyRegistry(args.worktree);
132
137
  return engineAdd({
133
138
  kind: args.kind,
134
139
  text: args.text,
135
140
  files: args.files,
136
141
  spec: args.spec,
137
142
  key: args.key,
143
+ knownDomains: reg.path ? reg.domains : null,
138
144
  });
139
145
  }
140
146
 
@@ -186,7 +192,7 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
186
192
  // slip through as undefined (silent drop = reject-without-saying, SPEC §Validation).
187
193
  if (args.key !== undefined && typeof args.key !== "string") {
188
194
  return errorResult(
189
- 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
195
+ 'key must be a string matching [a-z0-9-]{3,40}(:[a-z0-9-]{3,40})? — e.g. "fix-stop-dedupe" or "auth:login"',
190
196
  );
191
197
  }
192
198
  const key = typeof args.key === "string" ? args.key : undefined;
@@ -19,7 +19,7 @@ import { errorResult, type ToolResult } from "./types.js";
19
19
 
20
20
  const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
21
21
 
22
- When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session. Name a known problem with --key <a-z0-9-id> and recall every row for it via mem_find key.
22
+ When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session. Name a known problem with --key <domain:sub> and recall every row for it via mem_find key.
23
23
 
24
24
  worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
25
25
 
@@ -92,8 +92,10 @@ export interface RolePricing {
92
92
  export const FAPONY_DIR = ".fapony";
93
93
  export const CONFIG_FILENAME = "fapony.config.json";
94
94
  export const CONVENTIONS_FILENAME = "conventions.json";
95
+ export const KEYS_FILENAME = "keys.json";
95
96
  export const EVIDENCE_FILENAME = "evidence.json";
96
97
  export const CONVENTIONS_FILE = `${FAPONY_DIR}/${CONVENTIONS_FILENAME}`;
98
+ export const KEYS_FILE = `${FAPONY_DIR}/${KEYS_FILENAME}`;
97
99
  // plan/spec live in .fapony/ — not configurable (gitignored = private).
98
100
  export const PLAN_DIR = `${FAPONY_DIR}/plan`;
99
101
  export const SPEC_DIR = `${FAPONY_DIR}/spec`;
@@ -4,7 +4,8 @@
4
4
  // feature imports and are reused by stop, read-hint, edit-hint, and
5
5
  // session-start adapters.
6
6
 
7
- import { basename } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { basename, join } from "node:path";
8
9
 
9
10
  /** UTC 'YYYY-MM-DD HH:MM:SS' — the format events.ts is written in. */
10
11
  export function utcStamp(d: Date): string {
@@ -29,3 +30,10 @@ export function sessionKey(session: string): string {
29
30
  const base = basename(session).replace(/\.[^.]+$/, "");
30
31
  return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
31
32
  }
33
+
34
+ /** Per-session hint state dir (read log + shown mem ids) — disposable, never in a worktree. */
35
+ export function readTrackDir(): string {
36
+ const base =
37
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
38
+ return join(base, "read-track");
39
+ }
@@ -31,12 +31,31 @@ export interface MemRow {
31
31
  /** One mem-row text budget for any hint/seed surface that shows a row (was context-data's private const). */
32
32
  export const MEM_TEXT_MAX = 120;
33
33
 
34
+ /** Cut a row's text to MEM_TEXT_MAX at a word boundary, marked with `…` — never mid-word. */
35
+ export function clipMemText(text: string): string {
36
+ const flat = text.replace(/\s+/g, " ").trim();
37
+ if (flat.length <= MEM_TEXT_MAX) return flat;
38
+ const cut = flat.slice(0, MEM_TEXT_MAX - 1);
39
+ const space = cut.lastIndexOf(" ");
40
+ // ponytail: no space in the back half (one long token/path) = hard cut
41
+ return `${(space > MEM_TEXT_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
42
+ }
43
+
34
44
  /**
35
45
  * The only accepted shape of a mem row `key` — problem identity, not action
36
46
  * (kind) and not place (files[]). Lives here in core so CLI and MCP validate
37
47
  * through one regex (PLAN-unify-mem-engine: one engine, both surfaces).
48
+ *
49
+ * Bare (`fix-stop-dedupe`) or namespaced (`auth:login`, `nope:x`): the domain
50
+ * half is checked against `.fapony/keys.json` when that file exists
51
+ * (PLAN-mem-core chunk 4); the sub half is free (≥1 char — it names one
52
+ * problem, not a namespace). One colon at most — the engine splits on the
53
+ * first one, so a second colon never validates.
38
54
  */
39
- export const KEY_RE = /^[a-z0-9-]{3,40}$/;
55
+ export const KEY_RE = /^[a-z0-9-]{3,40}(:[a-z0-9-]{1,40})?$/;
56
+
57
+ /** A single key segment (domain in the registry, sub after the colon). */
58
+ export const KEY_SEGMENT_RE = /^[a-z0-9-]{3,40}$/;
40
59
 
41
60
  interface RawMemRow {
42
61
  ts?: string;
@@ -238,7 +238,7 @@ export const FaponyReadHint = async ({ directory }) => {
238
238
  session: input.sessionID,
239
239
  });
240
240
  if (reread) parts.push(reread);
241
- const ctx = readContextData(filePath, directory);
241
+ const ctx = readContextData(filePath, directory, input.sessionID);
242
242
  if (ctx) {
243
243
  for (const line of [...ctx.debtLines, ...ctx.memLines]) {
244
244
  parts.push(line);
@@ -384,7 +384,7 @@ export const FaponyEditHint = async ({ directory }) => {
384
384
  });
385
385
  if (hint) parts.push(hint);
386
386
  // Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
387
- const ctx = readContextData(filePath, directory);
387
+ const ctx = readContextData(filePath, directory, input.sessionID);
388
388
  if (ctx) {
389
389
  for (const line of [...ctx.debtLines, ...ctx.memLines]) {
390
390
  parts.push(line);
@@ -2,16 +2,40 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
+ import { collectSourceFiles } from "../../analyze/index.js";
5
6
  import { parseSince } from "../../core/since.js";
6
7
  import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
7
8
  import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
9
+ import { loadKeyRegistry } from "../key-registry.js";
8
10
  import { doneLines, fmtClose, fmtRow } from "../render.js";
9
- import { claimsOf, openKeys, openRows, staleReport } from "../selectors.js";
11
+ import {
12
+ claimsOf,
13
+ evictionReport,
14
+ openKeys,
15
+ openRows,
16
+ staleReport,
17
+ } from "../selectors.js";
10
18
  import type { CloseRow, LogRow, WorkRow } from "../store.js";
11
- import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
19
+ import { allRows, app, LOG, memCmd, planDir, root, rows } from "../store.js";
12
20
  import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
13
21
  import { THRESHOLD } from "./rotate.js";
14
22
 
23
+ /**
24
+ * One ⚠ line when the mem log is gitignored — the log's promise is that a
25
+ * clone carries every decision; an ignored log keeps them on this machine
26
+ * only, and nothing else says so. Ignoring may be deliberate (a public repo),
27
+ * so this warns, never blocks.
28
+ */
29
+ function ignoredLogLine(): string | null {
30
+ try {
31
+ const p = Bun.spawnSync(["git", "check-ignore", "-q", LOG], { cwd: root });
32
+ if (p.exitCode !== 0) return null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ return `⚠ mem log is gitignored (${basename(LOG)}) — decisions and plan ticks stay on this machine; a clone gets none of them`;
37
+ }
38
+
15
39
  /** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
16
40
  function getBranchDiffFiles(cwd: string): Set<string> {
17
41
  try {
@@ -65,6 +89,14 @@ const openKeysLine = (all: LogRow[]): string => {
65
89
  return `open keys: ${body}${more} — ${memCmd} find --key <key>`;
66
90
  };
67
91
 
92
+ // Registry line (PLAN-mem-core chunk 4): one line naming the allowed domains
93
+ // so the agent keys the row right the first time — silent with no registry.
94
+ const keyDomainsLine = (): string => {
95
+ const reg = loadKeyRegistry(root || process.cwd());
96
+ if (!reg.path || !reg.domains.length) return "";
97
+ return `key domains: ${reg.domains.join(", ")} — use --key domain:sub`;
98
+ };
99
+
68
100
  const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
69
101
  if (text.length <= max) return text;
70
102
  const cut = text.slice(0, max);
@@ -78,7 +110,23 @@ export const cmdDone = () => {
78
110
  };
79
111
 
80
112
  export const cmdStale = () => {
81
- for (const l of staleReport(rows())) console.log(l);
113
+ const all = rows();
114
+ for (const l of staleReport(all)) console.log(l);
115
+ // Eviction (PLAN-mem-core chunk 5): rows whose files[] are all gone from
116
+ // disk, with unique-basename moves followed. Local only — existsSync +
117
+ // collectSourceFiles, never git. The tree walk runs only when some row
118
+ // actually names a missing file.
119
+ if (!root) return;
120
+ const exists = (f: string) => existsSync(join(root, f));
121
+ if (
122
+ !all.some(
123
+ (r) =>
124
+ "files" in r && ((r as WorkRow).files ?? []).some((f) => !exists(f)),
125
+ )
126
+ )
127
+ return;
128
+ for (const l of evictionReport(all, exists, collectSourceFiles(root)))
129
+ console.log(l);
82
130
  };
83
131
 
84
132
  export const cmdFind = (a: string[]) => {
@@ -553,8 +601,12 @@ export const cmdKickoff = (a: string[]) => {
553
601
  if (!arg && !planFile) {
554
602
  // no args = ranked open rows + next up + recent closes
555
603
  console.log(`# ${app} — ${all.length} entries`);
604
+ const ignored = ignoredLogLine();
605
+ if (ignored) console.log(ignored);
556
606
  const keys = openKeysLine(all);
557
607
  if (keys) console.log(keys);
608
+ const domains = keyDomainsLine();
609
+ if (domains) console.log(domains);
558
610
 
559
611
  const open = openRows(all);
560
612
  const claims = claimsOf(all);
@@ -714,6 +766,8 @@ export const cmdKickoff = (a: string[]) => {
714
766
  } else if (planFile) {
715
767
  const title = readPlanTitle(planFile);
716
768
  console.log(`# ${title || basename(planFile)} — plan`);
769
+ const ignored = ignoredLogLine();
770
+ if (ignored) console.log(ignored);
717
771
  if (planCheckboxes.length) {
718
772
  console.log(
719
773
  `\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
@@ -1,6 +1,7 @@
1
1
  // commands/write.ts — mutating commands: add, close, claim, release, synced, hook
2
2
 
3
3
  import { CapError, engineAdd, engineClose } from "../engine.js";
4
+ import { loadKeyRegistry } from "../key-registry.js";
4
5
  import { claimsOf, openRows } from "../selectors.js";
5
6
  import type { WorkKind } from "../store.js";
6
7
  import { KINDS, memCmd, put, root, rows } from "../store.js";
@@ -125,8 +126,18 @@ export const cmdAdd = async (a: string[]) => {
125
126
  }
126
127
  // Domain rules (caps, id) live in the shared engine — this wrapper owns
127
128
  // only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
129
+ // The registry lives beside the mem log; absent = free-form keys (never
130
+ // reject). root is set by initStore at dispatch; cwd covers direct calls.
128
131
  try {
129
- const { id } = engineAdd({ kind: a[0], text, spec, files, key: keyVal });
132
+ const reg = loadKeyRegistry(root || process.cwd());
133
+ const { id } = engineAdd({
134
+ kind: a[0],
135
+ text,
136
+ spec,
137
+ files,
138
+ key: keyVal,
139
+ knownDomains: reg.path ? reg.domains : null,
140
+ });
130
141
  console.log(id);
131
142
  } catch (e) {
132
143
  if (e instanceof CapError) {
package/src/mem/engine.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // (PLAN-unify-mem-engine chunk 1)
12
12
 
13
13
  import { KEY_RE } from "../core/mem-log.js";
14
+ import { checkKeyDomain, keyMatchesQuery } from "./key-registry.js";
14
15
  import { openRows } from "./selectors.js";
15
16
  import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
16
17
 
@@ -38,6 +39,12 @@ export interface EngineAddArgs {
38
39
  spec?: string;
39
40
  /** Problem identity — optional, but validated against KEY_RE whenever present. */
40
41
  key?: string;
42
+ /**
43
+ * Registry domains for this repo (`.fapony/keys.json` via loadKeyRegistry).
44
+ * null/undefined = no registry on disk = free-form keys, never reject.
45
+ * Wrappers resolve it; the engine stays pure (no fs).
46
+ */
47
+ knownDomains?: string[] | null;
41
48
  }
42
49
 
43
50
  export interface EngineAddResult {
@@ -68,9 +75,13 @@ export function engineAdd(a: EngineAddArgs): EngineAddResult {
68
75
  // with a usable example, never silently drop the key (SPEC-mem-keys §Validation).
69
76
  if (a.key !== undefined && !KEY_RE.test(a.key)) {
70
77
  throw new Error(
71
- `key must match [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe", got "${a.key}"`,
78
+ `key must match [a-z0-9-]{3,40}(:[a-z0-9-]{1,40})? — e.g. "fix-stop-dedupe" or "auth:login", got "${a.key}"`,
72
79
  );
73
80
  }
81
+ // Registry gate (PLAN-mem-core chunk 4): the domain half must be declared.
82
+ const domainError =
83
+ a.key !== undefined ? checkKeyDomain(a.key, a.knownDomains) : null;
84
+ if (domainError) throw new Error(domainError);
74
85
 
75
86
  const all = rows();
76
87
  if (a.kind === "next" && !process.env.MEM_FORCE) {
@@ -190,10 +201,12 @@ export interface EngineFindArgs {
190
201
  */
191
202
  open?: boolean;
192
203
  /**
193
- * Exact problem-identity match — rows whose effective key differs fall out
194
- * (v:1 rows included; they stay reachable via files/text). A close row
195
- * matches through the key of the work row its ref points at (derived at
196
- * read time, never stored). A pure miss also returns knownKeys.
204
+ * Problem-identity match — a bare domain doubles as a prefix (`auth`
205
+ * catches every `auth:*`; `auth:login` is exact). Rows whose effective key
206
+ * differs fall out (v:1 rows included; they stay reachable via
207
+ * files/text). A close row matches through the key of the work row its
208
+ * ref points at (derived at read time, never stored). A pure miss also
209
+ * returns knownKeys.
197
210
  */
198
211
  key?: string;
199
212
  }
@@ -248,10 +261,11 @@ export function engineFind<T extends FindableRow>(
248
261
  out = out.filter((r) => !drop.has(r.kind));
249
262
  }
250
263
 
251
- // Exact key match, before text/files — a wrong key must never fall through
252
- // to substring luck. Close rows derive their key from the ref'd work row
253
- // (read-time derive: match only; the row itself stays keyless). knownKeys
254
- // fires only on a pure key miss — answer a wrong guess with the real list.
264
+ // Domain-prefix key match, before text/files — a wrong key must never fall
265
+ // through to substring luck. Close rows derive their key from the ref'd
266
+ // work row (read-time derive: match only; the row itself stays keyless).
267
+ // knownKeys fires only on a pure key miss — answer a wrong guess with the
268
+ // real list.
255
269
  let knownKeys: string[] | undefined;
256
270
  if (a.key) {
257
271
  const want = a.key;
@@ -266,8 +280,8 @@ export function engineFind<T extends FindableRow>(
266
280
  (r.kind === "close" && typeof r.ref === "string"
267
281
  ? idKey.get(r.ref)
268
282
  : undefined);
269
- out = out.filter((r) => eff(r) === want);
270
- if (!all.some((r) => eff(r) === want)) {
283
+ out = out.filter((r) => keyMatchesQuery(eff(r), want));
284
+ if (!all.some((r) => keyMatchesQuery(eff(r), want))) {
271
285
  const distinct = new Set<string>();
272
286
  for (const r of all) {
273
287
  if (r.key) distinct.add(r.key);
package/src/mem/index.ts CHANGED
@@ -52,7 +52,7 @@ example: fapony mem kickoff .fapony/plan/PLAN-x.md`,
52
52
  add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [--key k] [spec.md]
53
53
  append one row to the mem log
54
54
  --files f1,f2 repo-relative paths this row is about (required)
55
- --key k problem identity, [a-z0-9-]{3,40} — same problem = same key (optional)
55
+ --key k problem identity, bare or domain:sub (e.g. auth:login) — the domain must be in .fapony/keys.json when that file exists (optional)
56
56
  --stdin read <text> from stdin (avoids shell metachar)
57
57
  example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts --key unify-mem-engine`,
58
58
  close: `usage: fapony mem close <id> "<what was done | commit>"
@@ -63,7 +63,7 @@ example: fapony mem close mt14 "fixed in a2c6beb"`,
63
63
  substring search over every row's text/spec/ref (rotated archives included) — bookkeeping kinds (close/synced/claim/release) hidden unless --kind names them
64
64
  --kind a,b include only these kinds (overrides the bookkeeping default)
65
65
  --files f1,f2 rows about these paths (stored files[] first, text/spec/ref fallback)
66
- --key k exact problem-identity match — a miss prints the known keys
66
+ --key k problem-identity match — a bare domain catches every domain:* (auth matches auth:login); a miss prints the known keys
67
67
  --since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
68
68
  --limit n max rows returned (default 20, newest first)
69
69
  example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
@@ -0,0 +1,116 @@
1
+ // src/mem/key-registry.ts — domain key registry (`domain:sub`).
2
+ //
3
+ // PLAN-mem-core chunk 4: keys an agent invents never converge (3%), so a repo
4
+ // that wants convergence declares its domains in `.fapony/keys.json`, beside
5
+ // `conventions.json` — fixed path, no config field (rule: never add a field
6
+ // derivable from structure). No file = keys stay free-form (never reject);
7
+ // a file = the domain half must be listed, the sub half stays free.
8
+ //
9
+ // Shape: {"domains": ["auth", "hook"]} (a bare ["auth"] array reads the same).
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { FAPONY_DIR, KEYS_FILE, KEYS_FILENAME } from "../core/config.js";
14
+ import { KEY_SEGMENT_RE } from "../core/mem-log.js";
15
+ import { resolveMemDir } from "../memory.js";
16
+
17
+ /** Split `domain:sub` — null domain = a bare legacy key, always allowed. */
18
+ export function splitKey(key: string): { domain: string; sub: string } | null {
19
+ const i = key.indexOf(":");
20
+ if (i < 0) return null;
21
+ return { domain: key.slice(0, i), sub: key.slice(i + 1) };
22
+ }
23
+
24
+ /**
25
+ * A bare domain query doubles as a prefix: `auth` matches the exact key
26
+ * `auth` and every `auth:*`. A query carrying a colon is an exact match —
27
+ * `auth:login` never pulls in `auth:logout`.
28
+ */
29
+ export function keyMatchesQuery(
30
+ rowKey: string | undefined,
31
+ query: string,
32
+ ): boolean {
33
+ if (!rowKey) return false;
34
+ if (query.includes(":")) return rowKey === query;
35
+ return rowKey === query || rowKey.startsWith(`${query}:`);
36
+ }
37
+
38
+ export function resolveKeysPath(worktree: string): string | null {
39
+ // Same anchor as conventions.json: the .fapony/ dir holding the mem log,
40
+ // so the registry and the log cannot drift apart.
41
+ const memDir = resolveMemDir(worktree);
42
+ const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
43
+ const app = join(base, KEYS_FILENAME);
44
+ if (existsSync(app)) return app;
45
+ const root = join(worktree, KEYS_FILE);
46
+ return existsSync(root) ? root : null;
47
+ }
48
+
49
+ export interface KeyRegistry {
50
+ path: string | null;
51
+ /** Sorted, deduped, segment-valid — the list reject messages print. */
52
+ domains: string[];
53
+ warnings: string[];
54
+ }
55
+
56
+ /** Missing file = empty + no error (same contract as conventions.json). */
57
+ export function loadKeyRegistry(worktree: string): KeyRegistry {
58
+ const path = resolveKeysPath(worktree);
59
+ if (!path) return { path: null, domains: [], warnings: [] };
60
+ let raw: string;
61
+ try {
62
+ raw = readFileSync(path, "utf-8");
63
+ } catch {
64
+ return { path, domains: [], warnings: [`keys.json unreadable: ${path}`] };
65
+ }
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(raw);
69
+ } catch (e) {
70
+ return {
71
+ path,
72
+ domains: [],
73
+ warnings: [
74
+ `keys.json is not valid JSON — ${
75
+ e instanceof Error ? e.message.split("\n")[0] : "parse error"
76
+ }`,
77
+ ],
78
+ };
79
+ }
80
+ const rows: unknown[] = Array.isArray(parsed)
81
+ ? parsed
82
+ : Array.isArray((parsed as { domains?: unknown }).domains)
83
+ ? (parsed as { domains: unknown[] }).domains
84
+ : [];
85
+ const seen = new Set<string>();
86
+ let dropped = 0;
87
+ for (const r of rows) {
88
+ if (typeof r === "string" && KEY_SEGMENT_RE.test(r)) seen.add(r);
89
+ else dropped++;
90
+ }
91
+ const warnings =
92
+ dropped > 0
93
+ ? [
94
+ `keys.json: ${dropped} entr${dropped === 1 ? "y" : "ies"} dropped (not [a-z0-9-]{3,40})`,
95
+ ]
96
+ : [];
97
+ return { path, domains: [...seen].sort(), warnings };
98
+ }
99
+
100
+ /**
101
+ * null domains = no registry on disk = free-form keys (never reject).
102
+ * A registry gates only the domain half; bare keys stay backward compatible.
103
+ */
104
+ export function checkKeyDomain(
105
+ key: string,
106
+ domains: string[] | null | undefined,
107
+ ): string | null {
108
+ if (domains == null) return null;
109
+ const split = splitKey(key);
110
+ if (!split) return null;
111
+ if (domains.includes(split.domain)) return null;
112
+ return (
113
+ `unknown key domain "${split.domain}" — known domains: ` +
114
+ (domains.length ? domains.join(", ") : "(none yet)")
115
+ );
116
+ }
@@ -131,6 +131,73 @@ export const staleReport = (all: LogRow[]): string[] => {
131
131
  return out;
132
132
  };
133
133
 
134
+ // Eviction (PLAN-mem-core chunk 5): rows whose files[] no longer resolve on
135
+ // disk. Local only — the caller passes existence, this module never touches
136
+ // fs or git (plan §4: eviction ไม่เรียก git เลย). Append-only: these selectors
137
+ // report and redirect, they never delete rows.
138
+ export const evictedRows = (
139
+ all: LogRow[],
140
+ exists: (rel: string) => boolean,
141
+ ): WorkRow[] =>
142
+ openRows(all).filter(
143
+ (r) =>
144
+ (r.files ?? []).length > 0 && (r.files ?? []).every((f) => !exists(f)),
145
+ );
146
+
147
+ // Follow a move by basename: a missing path whose basename names exactly one
148
+ // file on disk is treated as moved there. Two files sharing the basename =
149
+ // ambiguous → no entry (silent, never guessed). A rename that changes the
150
+ // basename itself is out of reach — said plainly, not guessed at.
151
+ export const movedTargets = (
152
+ missing: string[],
153
+ onDisk: string[],
154
+ ): Map<string, string> => {
155
+ const byBase = new Map<string, string[]>();
156
+ for (const f of onDisk) {
157
+ const b = f.slice(f.lastIndexOf("/") + 1);
158
+ const list = byBase.get(b) ?? [];
159
+ list.push(f);
160
+ byBase.set(b, list);
161
+ }
162
+ const out = new Map<string, string>();
163
+ for (const old of missing) {
164
+ const b = old.slice(old.lastIndexOf("/") + 1);
165
+ const cands = (byBase.get(b) ?? []).filter((f) => f !== old);
166
+ if (cands.length === 1) out.set(old, cands[0]);
167
+ }
168
+ return out;
169
+ };
170
+
171
+ // One report for `mem stale`: EVICTED for open rows whose files[] are all
172
+ // gone, MOVED for rows rescued by a unique-basename match. Rows with ≥1 file
173
+ // still on disk stay silent — mem find --files still reaches them.
174
+ export const evictionReport = (
175
+ all: LogRow[],
176
+ exists: (rel: string) => boolean,
177
+ onDisk: string[],
178
+ ): string[] => {
179
+ const open = openRows(all);
180
+ const gone = (files: string[]): string[] => files.filter((f) => !exists(f));
181
+ const moved = movedTargets(
182
+ [...new Set(open.flatMap((r) => gone(r.files ?? [])))],
183
+ onDisk,
184
+ );
185
+ const out: string[] = [];
186
+ for (const r of evictedRows(all, exists)) {
187
+ const missing = gone(r.files ?? []);
188
+ const rescued = missing.filter((f) => moved.has(f));
189
+ if (rescued.length === missing.length) {
190
+ const hops = rescued.map((f) => `${f} → ${moved.get(f)}`).join(", ");
191
+ out.push(`MOVED [${r.id}] ${r.kind} ${hops} — "${r.text}"`);
192
+ } else {
193
+ out.push(
194
+ `EVICTED [${r.id}] ${r.kind} files gone: ${missing.join(", ")} — "${r.text}"`,
195
+ );
196
+ }
197
+ }
198
+ return out;
199
+ };
200
+
134
201
  // rotate: rows that must carry over into the new log file after archiving
135
202
  // - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
136
203
  // - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced: