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 +1 -1
- package/src/adapters/hooks/context-data.ts +88 -16
- package/src/adapters/hooks/edit-hint.ts +1 -1
- package/src/adapters/hooks/read-hint.ts +2 -12
- package/src/adapters/mcp/tools/index.ts +2 -2
- package/src/adapters/mcp/tools/mem.ts +8 -2
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/core/config.ts +2 -0
- package/src/core/hook-helpers.ts +9 -1
- package/src/core/mem-log.ts +20 -1
- package/src/install/opencode.ts +2 -2
- package/src/mem/commands/read.ts +57 -3
- package/src/mem/commands/write.ts +12 -1
- package/src/mem/engine.ts +25 -11
- package/src/mem/index.ts +2 -2
- package/src/mem/key-registry.ts +116 -0
- package/src/mem/selectors.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.
|
|
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
143
|
+
if (!filesOf(r).includes(rel)) continue;
|
|
86
144
|
openLines.push(
|
|
87
|
-
`fapony mem: OPEN BUG ${r.id} — "${r.text
|
|
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
|
-
|
|
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")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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 = [
|
|
115
|
-
.
|
|
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)
|
|
190
|
+
if (r.id) restIds.push(r.id);
|
|
121
191
|
memLines.push(
|
|
122
|
-
`fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text
|
|
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 <
|
|
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
|
|
package/src/core/config.ts
CHANGED
|
@@ -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`;
|
package/src/core/hook-helpers.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/src/core/mem-log.ts
CHANGED
|
@@ -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;
|
package/src/install/opencode.ts
CHANGED
|
@@ -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);
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
-
//
|
|
252
|
-
// to substring luck. Close rows derive their key from the ref'd
|
|
253
|
-
// (read-time derive: match only; the row itself stays keyless).
|
|
254
|
-
// fires only on a pure key miss — answer a wrong guess with the
|
|
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)
|
|
270
|
-
if (!all.some((r) => eff(r)
|
|
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,
|
|
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
|
|
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
|
+
}
|
package/src/mem/selectors.ts
CHANGED
|
@@ -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:
|