fapony 0.7.0 → 0.7.1
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 +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -8
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +2 -11
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -211
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -400
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -258
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -394
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -792
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -284
- package/src/mem/engine.ts +0 -329
- package/src/mem/index.ts +0 -201
- package/src/mem/key-registry.ts +0 -116
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -227
- package/src/mem/store.ts +0 -356
package/src/analyze/cache.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
// src/analyze/cache.ts — session-scoped graph cache.
|
|
2
|
-
//
|
|
3
|
-
// buildGraph is cheap on this repo (~50ms/150 files) but the Edit hint calls it
|
|
4
|
-
// on every Edit — and a Claude Code PreToolUse hook is a *fresh process per
|
|
5
|
-
// tool call*, so an in-process cache alone never survives to the next edit. The
|
|
6
|
-
// graph is therefore mirrored to <faponyDir>/graph-cache/<key>.json:
|
|
7
|
-
// - auto-build: every build is written through, best-effort (never blocks)
|
|
8
|
-
// - auto-invalidate: a fingerprint over the source-file set (rel path + size
|
|
9
|
-
// + mtimeMs) is stored beside the graph; a mismatch means rebuild
|
|
10
|
-
// - cost: the cache file is only stat-ed when there is one to validate, so a
|
|
11
|
-
// first-ever call pays build + write; later calls pay a walk + stat + parse,
|
|
12
|
-
// well under a rebuild on any repo large enough for this to matter
|
|
13
|
-
// Everything here is derived state — an unreadable, corrupt, stale or
|
|
14
|
-
// unwritable cache falls back to a live build and no code path trusts it.
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
existsSync,
|
|
18
|
-
mkdirSync,
|
|
19
|
-
readFileSync,
|
|
20
|
-
renameSync,
|
|
21
|
-
statSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
} from "node:fs";
|
|
24
|
-
import { join, resolve } from "node:path";
|
|
25
|
-
|
|
26
|
-
import { faponyDir } from "../core/config.js";
|
|
27
|
-
import { collectSourceFiles } from "./discover.js";
|
|
28
|
-
import { buildGraph } from "./graph.js";
|
|
29
|
-
import type { ImportGraph } from "./types.js";
|
|
30
|
-
|
|
31
|
-
const GRAPH_CACHE_VERSION = 1;
|
|
32
|
-
const GRAPH_CACHE_DIR = "graph-cache";
|
|
33
|
-
|
|
34
|
-
interface SerializedGraph {
|
|
35
|
-
v: number;
|
|
36
|
-
fp: string;
|
|
37
|
-
files: string[];
|
|
38
|
-
deps: Record<string, string[]>;
|
|
39
|
-
dependents: Record<string, string[]>;
|
|
40
|
-
unresolved: number;
|
|
41
|
-
external: number;
|
|
42
|
-
barrels: string[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
let _graphCache: { dir: string; fp: string; graph: ImportGraph } | null = null;
|
|
46
|
-
|
|
47
|
-
/** Drop the in-process graph cache (tests simulate a fresh hook process). */
|
|
48
|
-
export function resetGraphCache(): void {
|
|
49
|
-
_graphCache = null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Absolute path of a worktree's graph-cache file — may not exist. */
|
|
53
|
-
export function graphCachePath(dir: string): string {
|
|
54
|
-
const abs = resolve(dir);
|
|
55
|
-
const slug = abs.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 60);
|
|
56
|
-
return join(
|
|
57
|
-
faponyDir(),
|
|
58
|
-
GRAPH_CACHE_DIR,
|
|
59
|
-
`${slug}-${Bun.hash(abs).toString(36)}.json`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// The graph changes only when the set of source files or their bytes change —
|
|
64
|
-
// size/mtime/ctime catch that without reading any file. ctime rides the same
|
|
65
|
-
// stat call for free and cannot be forged like mtime can (only the system
|
|
66
|
-
// moves it), so an mtime-preserving rewrite still invalidates.
|
|
67
|
-
function graphFingerprint(absDir: string): string {
|
|
68
|
-
const parts: string[] = [];
|
|
69
|
-
for (const rel of collectSourceFiles(absDir)) {
|
|
70
|
-
try {
|
|
71
|
-
const st = statSync(join(absDir, rel));
|
|
72
|
-
parts.push(
|
|
73
|
-
`${rel}\u0000${st.size}\u0000${st.mtimeMs}\u0000${st.ctimeMs}`,
|
|
74
|
-
);
|
|
75
|
-
} catch {
|
|
76
|
-
parts.push(`${rel}\u0000?\u0000?\u0000?`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return Bun.hash(parts.join("\n")).toString(36);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function serializeGraph(graph: ImportGraph, fp: string): SerializedGraph {
|
|
83
|
-
const rec = (m: Map<string, Set<string>>): Record<string, string[]> => {
|
|
84
|
-
const out: Record<string, string[]> = {};
|
|
85
|
-
for (const [k, v] of m) out[k] = [...v];
|
|
86
|
-
return out;
|
|
87
|
-
};
|
|
88
|
-
return {
|
|
89
|
-
v: GRAPH_CACHE_VERSION,
|
|
90
|
-
fp,
|
|
91
|
-
files: graph.files,
|
|
92
|
-
deps: rec(graph.deps),
|
|
93
|
-
dependents: rec(graph.dependents),
|
|
94
|
-
unresolved: graph.unresolved,
|
|
95
|
-
external: graph.external,
|
|
96
|
-
barrels: [...graph.barrels],
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function hydrateGraph(c: SerializedGraph): ImportGraph {
|
|
101
|
-
const toMap = (r: Record<string, string[]>): Map<string, Set<string>> => {
|
|
102
|
-
const m = new Map<string, Set<string>>();
|
|
103
|
-
for (const [k, v] of Object.entries(r)) m.set(k, new Set(v));
|
|
104
|
-
return m;
|
|
105
|
-
};
|
|
106
|
-
return {
|
|
107
|
-
files: c.files,
|
|
108
|
-
deps: toMap(c.deps),
|
|
109
|
-
dependents: toMap(c.dependents),
|
|
110
|
-
unresolved: c.unresolved,
|
|
111
|
-
external: c.external,
|
|
112
|
-
barrels: new Set(c.barrels),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function readCachedGraph(path: string, fp: string): ImportGraph | null {
|
|
117
|
-
try {
|
|
118
|
-
const cached = JSON.parse(readFileSync(path, "utf-8")) as SerializedGraph;
|
|
119
|
-
if (cached.v !== GRAPH_CACHE_VERSION || cached.fp !== fp) return null;
|
|
120
|
-
return hydrateGraph(cached);
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function writeCachedGraph(path: string, graph: ImportGraph, fp: string): void {
|
|
127
|
-
try {
|
|
128
|
-
if (!existsSync(join(faponyDir(), GRAPH_CACHE_DIR))) {
|
|
129
|
-
mkdirSync(join(faponyDir(), GRAPH_CACHE_DIR), { recursive: true });
|
|
130
|
-
}
|
|
131
|
-
// pid-suffixed temp + rename: a reader never sees a half-written file even
|
|
132
|
-
// when two hook processes race.
|
|
133
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
134
|
-
writeFileSync(tmp, JSON.stringify(serializeGraph(graph, fp)), "utf-8");
|
|
135
|
-
renameSync(tmp, path);
|
|
136
|
-
} catch {
|
|
137
|
-
// best-effort — a cache that cannot be written must not break the caller
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function buildGraphCached(dir: string): ImportGraph {
|
|
142
|
-
const abs = resolve(dir);
|
|
143
|
-
// One walk per call: the fingerprint doubles as the in-process validity
|
|
144
|
-
// check, so a same-process second call after an edit rebuilds instead of
|
|
145
|
-
// serving the stale graph. A drift between this fp and the built graph
|
|
146
|
-
// self-heals — the next call recomputes and rebuilds again.
|
|
147
|
-
const fp = graphFingerprint(abs);
|
|
148
|
-
if (_graphCache?.dir === abs && _graphCache.fp === fp)
|
|
149
|
-
return _graphCache.graph;
|
|
150
|
-
const path = graphCachePath(abs);
|
|
151
|
-
if (existsSync(path)) {
|
|
152
|
-
const cached = readCachedGraph(path, fp);
|
|
153
|
-
if (cached) {
|
|
154
|
-
_graphCache = { dir: abs, fp, graph: cached };
|
|
155
|
-
return cached;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
const graph = buildGraph(abs);
|
|
159
|
-
_graphCache = { dir: abs, fp, graph };
|
|
160
|
-
writeCachedGraph(path, graph, fp);
|
|
161
|
-
return graph;
|
|
162
|
-
}
|
package/src/core/mem-log.ts
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
// src/core/mem-log.ts — mem-log reader: directory discovery + JSONL parsing (pure, no feature imports)
|
|
2
|
-
//
|
|
3
|
-
// Extracted from src/memory.ts (PLAN-lib-layer chunk 2b). Shell adapter
|
|
4
|
-
// helpers (closeMemory, kickoffMemory, claimMemory) stay in memory.ts.
|
|
5
|
-
|
|
6
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
CONFIG_FILENAME,
|
|
10
|
-
DEFAULT_MEM_DIR,
|
|
11
|
-
FAPONY_DIR,
|
|
12
|
-
loadConfig,
|
|
13
|
-
} from "./config.js";
|
|
14
|
-
|
|
15
|
-
export interface MemRow {
|
|
16
|
-
ts: string;
|
|
17
|
-
agent: string;
|
|
18
|
-
kind: string;
|
|
19
|
-
text: string;
|
|
20
|
-
spec?: string;
|
|
21
|
-
id?: string;
|
|
22
|
-
ref?: string;
|
|
23
|
-
/** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
|
|
24
|
-
files?: string[];
|
|
25
|
-
/** Problem identity, distinct from kind (action) and files[] (place) — written by `mem add --key` (PLAN-mem-keys chunk 1). */
|
|
26
|
-
key?: string;
|
|
27
|
-
/** Row schema version: 2 = may carry `key`; absent = v:1 legacy row, read as-is. */
|
|
28
|
-
v?: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** One mem-row text budget for any hint/seed surface that shows a row (was context-data's private const). */
|
|
32
|
-
export const MEM_TEXT_MAX = 120;
|
|
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
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The only accepted shape of a mem row `key` — problem identity, not action
|
|
46
|
-
* (kind) and not place (files[]). Lives here in core so CLI and MCP validate
|
|
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.
|
|
54
|
-
*/
|
|
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}$/;
|
|
59
|
-
|
|
60
|
-
interface RawMemRow {
|
|
61
|
-
ts?: string;
|
|
62
|
-
agent?: string;
|
|
63
|
-
kind?: string;
|
|
64
|
-
text?: string;
|
|
65
|
-
spec?: string;
|
|
66
|
-
id?: string;
|
|
67
|
-
ref?: string;
|
|
68
|
-
files?: unknown;
|
|
69
|
-
key?: unknown;
|
|
70
|
-
v?: unknown;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* A directory is a mem dir only when it holds a `log.jsonl` or a named
|
|
75
|
-
* `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
|
|
76
|
-
* A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
|
|
77
|
-
* otherwise it would shadow an ancestor that holds the real log (SPEC §1).
|
|
78
|
-
*/
|
|
79
|
-
function hasMemLogs(dir: string): boolean {
|
|
80
|
-
try {
|
|
81
|
-
return readdirSync(dir).some(
|
|
82
|
-
(f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
|
|
83
|
-
);
|
|
84
|
-
} catch {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Physical path (symlinks resolved) so start and repo root compare like with
|
|
90
|
-
* like; falls back to a lexical resolve when the path does not exist yet. */
|
|
91
|
-
function physical(p: string): string {
|
|
92
|
-
try {
|
|
93
|
-
return realpathSync(p);
|
|
94
|
-
} catch {
|
|
95
|
-
return resolve(p);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
|
|
100
|
-
function gitRootOf(fromDir: string): string | null {
|
|
101
|
-
try {
|
|
102
|
-
const { execSync } =
|
|
103
|
-
require("node:child_process") as typeof import("node:child_process");
|
|
104
|
-
const root = execSync("git rev-parse --show-toplevel", {
|
|
105
|
-
cwd: fromDir,
|
|
106
|
-
encoding: "utf-8",
|
|
107
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
108
|
-
}).trim();
|
|
109
|
-
return root || null;
|
|
110
|
-
} catch {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The lexical ancestor of `fromDir` that is the git repo root, or null outside
|
|
117
|
-
* a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
|
|
118
|
-
* var on macOS), so the walk compares `physical(dir)` against it and returns the
|
|
119
|
-
* path in the caller's own lexical form — the returned dir must match what the
|
|
120
|
-
* caller passed in, not a canonicalized stranger.
|
|
121
|
-
*/
|
|
122
|
-
function repoRootOf(fromDir: string): string | null {
|
|
123
|
-
const start = resolve(fromDir);
|
|
124
|
-
const gitRoot = gitRootOf(start);
|
|
125
|
-
if (!gitRoot) return null;
|
|
126
|
-
const physicalRoot = physical(gitRoot);
|
|
127
|
-
let dir = start;
|
|
128
|
-
while (true) {
|
|
129
|
-
if (physical(dir) === physicalRoot) return dir;
|
|
130
|
-
const parent = dirname(dir);
|
|
131
|
-
if (parent === dir) return null;
|
|
132
|
-
dir = parent;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
|
|
138
|
-
*
|
|
139
|
-
* The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
|
|
140
|
-
* checkout is never ours. Outside a repo it stops at the filesystem root.
|
|
141
|
-
* `acceptEmpty` is for the default-wiring check, which only needs the dir the
|
|
142
|
-
* writer will use (log or not); the resolver itself requires a real log.
|
|
143
|
-
*/
|
|
144
|
-
export function walkUpForMemDir(
|
|
145
|
-
fromDir: string,
|
|
146
|
-
acceptEmpty: boolean,
|
|
147
|
-
): string | null {
|
|
148
|
-
const start = resolve(fromDir);
|
|
149
|
-
const boundary = repoRootOf(start) ?? "/";
|
|
150
|
-
let dir = start;
|
|
151
|
-
while (true) {
|
|
152
|
-
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
153
|
-
if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
|
|
154
|
-
return candidate;
|
|
155
|
-
}
|
|
156
|
-
if (dir === boundary) break;
|
|
157
|
-
const parent = dirname(dir);
|
|
158
|
-
if (parent === dir) break;
|
|
159
|
-
dir = parent;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface MemDirResult {
|
|
165
|
-
dir: string | null;
|
|
166
|
-
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
167
|
-
candidates?: string[];
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Dirs that never hold a project mem log and are expensive to walk.
|
|
171
|
-
const SCAN_SKIP = new Set([
|
|
172
|
-
"node_modules",
|
|
173
|
-
".git",
|
|
174
|
-
"dist",
|
|
175
|
-
"build",
|
|
176
|
-
"coverage",
|
|
177
|
-
"vendor",
|
|
178
|
-
"out",
|
|
179
|
-
"target",
|
|
180
|
-
".next",
|
|
181
|
-
".turbo",
|
|
182
|
-
".cache",
|
|
183
|
-
]);
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
|
|
187
|
-
* shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
|
|
188
|
-
* fail example): if two or more app-scoped logs exist and nothing at/above cwd
|
|
189
|
-
* holds one, the caller must refuse rather than start a third log at the root.
|
|
190
|
-
*/
|
|
191
|
-
function findMemDirsUnder(root: string): string[] {
|
|
192
|
-
const found: string[] = [];
|
|
193
|
-
const walk = (dir: string, depth: number): void => {
|
|
194
|
-
if (depth > 5) return;
|
|
195
|
-
let entries: import("node:fs").Dirent[];
|
|
196
|
-
try {
|
|
197
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
198
|
-
} catch {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
for (const e of entries) {
|
|
202
|
-
if (!e.isDirectory()) continue;
|
|
203
|
-
if (e.name === FAPONY_DIR) {
|
|
204
|
-
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
205
|
-
if (hasMemLogs(candidate)) found.push(candidate);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
|
|
209
|
-
walk(join(dir, e.name), depth + 1);
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
walk(root, 0);
|
|
213
|
-
return found;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The single resolver: returns the mem dir and which step won.
|
|
218
|
-
*
|
|
219
|
-
* Resolution order (SPEC §1):
|
|
220
|
-
* 1. `--mem-dir <path>` (wins over everything)
|
|
221
|
-
* 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
|
|
222
|
-
* 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
|
|
223
|
-
* 4. `<repo root>/.fapony/.memory/`
|
|
224
|
-
*/
|
|
225
|
-
function resolveMemDirFrom(
|
|
226
|
-
fromDir: string,
|
|
227
|
-
explicitOverride?: string,
|
|
228
|
-
): MemDirResult {
|
|
229
|
-
// Step 1: --mem-dir flag
|
|
230
|
-
if (explicitOverride) {
|
|
231
|
-
return {
|
|
232
|
-
dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
|
|
233
|
-
step: "flag",
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const cwd = resolve(fromDir);
|
|
238
|
-
const root = repoRootOf(cwd);
|
|
239
|
-
|
|
240
|
-
// Step 2: paths.memDir in the repo-root config
|
|
241
|
-
const configDir = root ?? cwd;
|
|
242
|
-
try {
|
|
243
|
-
const configPath = join(configDir, CONFIG_FILENAME);
|
|
244
|
-
if (existsSync(configPath)) {
|
|
245
|
-
const config = loadConfig(configPath);
|
|
246
|
-
if (config?.paths?.memDir) {
|
|
247
|
-
const absCfg = isAbsolute(config.paths.memDir)
|
|
248
|
-
? config.paths.memDir
|
|
249
|
-
: join(configDir, config.paths.memDir);
|
|
250
|
-
if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
} catch {
|
|
254
|
-
// no config or unreadable — continue
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Step 3: walk up from cwd, only a dir with a real log counts
|
|
258
|
-
const walked = walkUpForMemDir(cwd, false);
|
|
259
|
-
if (walked) return { dir: walked, step: "walk-up" };
|
|
260
|
-
|
|
261
|
-
// Guard (SPEC §1 fail example)
|
|
262
|
-
const candidates = root ? findMemDirsUnder(root) : [];
|
|
263
|
-
if (candidates.length >= 2) {
|
|
264
|
-
return { dir: null, step: "ambiguous", candidates };
|
|
265
|
-
}
|
|
266
|
-
const outOfScope = candidates.length ? { candidates } : {};
|
|
267
|
-
|
|
268
|
-
// Step 4: <repo root>/.fapony/.memory/
|
|
269
|
-
if (root) {
|
|
270
|
-
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
271
|
-
if (existsSync(rootDir)) {
|
|
272
|
-
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return { dir: null, step: "none", ...outOfScope };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function resolveMemDir(
|
|
280
|
-
worktree?: string,
|
|
281
|
-
explicitOverride?: string,
|
|
282
|
-
): string | null {
|
|
283
|
-
return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Resolve and report which step won — for `fapony mem where`. */
|
|
287
|
-
export function whereMemDir(
|
|
288
|
-
fromDir?: string,
|
|
289
|
-
explicitOverride?: string,
|
|
290
|
-
): MemDirResult {
|
|
291
|
-
return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Read every `log*.jsonl` row under the worktree's memory dir, newest first.
|
|
296
|
-
* `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
|
|
297
|
-
* Malformed/unreadable rows are counted, never thrown — a corrupt line must
|
|
298
|
-
* not take down the caller.
|
|
299
|
-
*/
|
|
300
|
-
export function readMemLog(
|
|
301
|
-
worktree: string,
|
|
302
|
-
sinceIso?: string,
|
|
303
|
-
): { rows: MemRow[]; skipped: number; filesFound: number } {
|
|
304
|
-
const dir = resolveMemDir(worktree);
|
|
305
|
-
if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
|
|
306
|
-
|
|
307
|
-
const isLogFile = (f: string): boolean =>
|
|
308
|
-
f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
|
|
309
|
-
|
|
310
|
-
let files: string[];
|
|
311
|
-
try {
|
|
312
|
-
files = readdirSync(dir)
|
|
313
|
-
.filter(isLogFile)
|
|
314
|
-
.sort()
|
|
315
|
-
.map((f) => join(dir, f));
|
|
316
|
-
} catch {
|
|
317
|
-
return { rows: [], skipped: 0, filesFound: 0 };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (files.length === 0) return { rows: [], skipped: 0, filesFound: 0 };
|
|
321
|
-
|
|
322
|
-
let skipped = 0;
|
|
323
|
-
const all: MemRow[] = [];
|
|
324
|
-
|
|
325
|
-
for (const file of files) {
|
|
326
|
-
let raw: string;
|
|
327
|
-
try {
|
|
328
|
-
raw = readFileSync(file, "utf-8");
|
|
329
|
-
} catch {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
const lines = raw.split("\n").filter(Boolean);
|
|
333
|
-
for (const line of lines) {
|
|
334
|
-
let parsed: RawMemRow;
|
|
335
|
-
try {
|
|
336
|
-
parsed = JSON.parse(line) as RawMemRow;
|
|
337
|
-
} catch {
|
|
338
|
-
skipped++;
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
if (!parsed.ts || !parsed.kind) {
|
|
342
|
-
skipped++;
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
if (sinceIso && parsed.ts < sinceIso) continue;
|
|
346
|
-
all.push({
|
|
347
|
-
ts: parsed.ts,
|
|
348
|
-
agent: parsed.agent ?? "unknown",
|
|
349
|
-
kind: parsed.kind,
|
|
350
|
-
text: parsed.text ?? "",
|
|
351
|
-
spec: parsed.spec,
|
|
352
|
-
id: parsed.id,
|
|
353
|
-
ref: parsed.ref,
|
|
354
|
-
...(Array.isArray(parsed.files)
|
|
355
|
-
? { files: parsed.files.filter((f) => typeof f === "string") }
|
|
356
|
-
: {}),
|
|
357
|
-
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
|
|
358
|
-
...(typeof parsed.v === "number" ? { v: parsed.v } : {}),
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
all.sort((a, b) => b.ts.localeCompare(a.ts)); // newest first
|
|
364
|
-
return { rows: all, skipped, filesFound: files.length };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Decisions for the pre-edit context summary — newest first, capped at `limit`.
|
|
369
|
-
*/
|
|
370
|
-
export function readRecentMemDecisions(
|
|
371
|
-
worktree: string,
|
|
372
|
-
limit: number,
|
|
373
|
-
keywords?: string[],
|
|
374
|
-
): MemRow[] {
|
|
375
|
-
let decisions: MemRow[];
|
|
376
|
-
try {
|
|
377
|
-
decisions = readMemLog(worktree).rows.filter((r) => r.kind === "decision");
|
|
378
|
-
} catch {
|
|
379
|
-
return [];
|
|
380
|
-
}
|
|
381
|
-
if (decisions.length === 0) return [];
|
|
382
|
-
|
|
383
|
-
const kws = (keywords ?? [])
|
|
384
|
-
.map((k) => k.toLowerCase())
|
|
385
|
-
.filter((k) => k.length > 0);
|
|
386
|
-
if (kws.length > 0) {
|
|
387
|
-
const hits = decisions.filter((r) => {
|
|
388
|
-
const hay = `${r.text}\n${r.spec ?? ""}`.toLowerCase();
|
|
389
|
-
return kws.some((k) => hay.includes(k));
|
|
390
|
-
});
|
|
391
|
-
if (hits.length > 0) return hits.slice(0, limit);
|
|
392
|
-
}
|
|
393
|
-
return decisions.slice(0, limit);
|
|
394
|
-
}
|
package/src/init-mem.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
// src/init-mem.ts — cleanup old .memory/ directories and warn about stale call sites.
|
|
2
|
-
//
|
|
3
|
-
// Before PLAN-agent-one-call, this file scaffolded templates/mem/ into every repo.
|
|
4
|
-
// Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
|
|
5
|
-
// This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
|
|
6
|
-
|
|
7
|
-
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./core/config.js";
|
|
10
|
-
import { walkDir } from "./util.js";
|
|
11
|
-
|
|
12
|
-
export function cmdInitMem(args: string[]): void {
|
|
13
|
-
const force = args.includes("--force");
|
|
14
|
-
const worktreeKey = args.find((x) => !x.startsWith("-"));
|
|
15
|
-
const config = loadConfig();
|
|
16
|
-
|
|
17
|
-
// no key given = the repo you are standing in
|
|
18
|
-
const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
|
|
19
|
-
if (!worktree) {
|
|
20
|
-
console.error(`unknown worktree key: ${worktreeKey}`);
|
|
21
|
-
console.error(`available: ${Object.keys(config.worktrees).join(", ")}`);
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const gitRoot = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
26
|
-
cwd: worktree,
|
|
27
|
-
})
|
|
28
|
-
.stdout.toString()
|
|
29
|
-
.trim();
|
|
30
|
-
const root = gitRoot || worktree;
|
|
31
|
-
|
|
32
|
-
// 1) Find and delete .memory/ directories (legacy layout)
|
|
33
|
-
const memoryDirs = walkDir(root, {
|
|
34
|
-
includeDotDirs: [FAPONY_DIR],
|
|
35
|
-
predicate: (dir) => existsSync(join(dir, ".memory")),
|
|
36
|
-
}).map((dir) => join(dir, ".memory"));
|
|
37
|
-
|
|
38
|
-
// Legacy filenames that may live alongside live logs inside .fapony/.memory/
|
|
39
|
-
const LEGACY_MEM_FILENAMES = [
|
|
40
|
-
"mem.ts",
|
|
41
|
-
"store.ts",
|
|
42
|
-
"selectors.ts",
|
|
43
|
-
"render.ts",
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
if (memoryDirs.length === 0) {
|
|
47
|
-
console.log("no legacy .memory/ directories found — already clean");
|
|
48
|
-
} else {
|
|
49
|
-
let removed = 0;
|
|
50
|
-
let kept = 0;
|
|
51
|
-
for (const d of memoryDirs) {
|
|
52
|
-
// Any log*.jsonl is history — includes log.<person>.jsonl (the standard
|
|
53
|
-
// filename) and rotated log.YYYY-MM-DD.jsonl, not just log.jsonl.
|
|
54
|
-
let logs: string[] = [];
|
|
55
|
-
try {
|
|
56
|
-
logs = readdirSync(d).filter(
|
|
57
|
-
(f) => f === "log.jsonl" || /^log\..*\.jsonl$/.test(f),
|
|
58
|
-
);
|
|
59
|
-
} catch {
|
|
60
|
-
// unreadable dir — fall through and remove
|
|
61
|
-
}
|
|
62
|
-
// Inside .fapony/.memory/: remove known legacy .ts files while keeping
|
|
63
|
-
// live logs. The keep-if-logs guard would preserve the whole directory,
|
|
64
|
-
// but legacy scaffolding (mem.ts, store.ts, ...) is dead code that
|
|
65
|
-
// should not linger beside the active log.
|
|
66
|
-
const isFaponyMemory =
|
|
67
|
-
d.endsWith(`/${FAPONY_DIR}/.memory`) || d === `${FAPONY_DIR}/.memory`;
|
|
68
|
-
if (isFaponyMemory && logs.length > 0) {
|
|
69
|
-
let legacyRemoved = 0;
|
|
70
|
-
let commandsRemoved = 0;
|
|
71
|
-
for (const name of LEGACY_MEM_FILENAMES) {
|
|
72
|
-
const fp = join(d, name);
|
|
73
|
-
if (existsSync(fp)) {
|
|
74
|
-
rmSync(fp);
|
|
75
|
-
legacyRemoved++;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const commandsDir = join(d, "commands");
|
|
79
|
-
if (existsSync(commandsDir)) {
|
|
80
|
-
rmSync(commandsDir, { recursive: true, force: true });
|
|
81
|
-
commandsRemoved++;
|
|
82
|
-
}
|
|
83
|
-
if (legacyRemoved || commandsRemoved) {
|
|
84
|
-
console.log(
|
|
85
|
-
`cleaned ${d} — removed ${legacyRemoved} legacy file(s)${
|
|
86
|
-
commandsRemoved ? " + commands/" : ""
|
|
87
|
-
} (kept ${logs.length} log file(s): ${logs.join(", ")})`,
|
|
88
|
-
);
|
|
89
|
-
} else {
|
|
90
|
-
console.log(`${d} — already clean (logs: ${logs.join(", ")})`);
|
|
91
|
-
}
|
|
92
|
-
kept++;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (logs.length > 0 && !force) {
|
|
96
|
-
console.log(
|
|
97
|
-
`keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
|
|
98
|
-
);
|
|
99
|
-
console.log(
|
|
100
|
-
` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
|
|
101
|
-
);
|
|
102
|
-
kept++;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
console.log(
|
|
106
|
-
`deleting ${d}${logs.length > 0 ? ` (--force: ${logs.length} log file(s))` : ""}`,
|
|
107
|
-
);
|
|
108
|
-
rmSync(d, { recursive: true, force: true });
|
|
109
|
-
removed++;
|
|
110
|
-
}
|
|
111
|
-
console.log(
|
|
112
|
-
`\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
|
|
113
|
-
kept > 0
|
|
114
|
-
? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
|
|
115
|
-
: ""
|
|
116
|
-
}`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// 2) Warn about package.json call sites still referencing .memory/mem.ts
|
|
121
|
-
for (const dir of walkDir(root)) {
|
|
122
|
-
const pkg = join(dir, "package.json");
|
|
123
|
-
if (!existsSync(pkg)) continue;
|
|
124
|
-
try {
|
|
125
|
-
const raw = readFileSync(pkg, "utf8");
|
|
126
|
-
if (raw.includes(".memory/mem.ts")) {
|
|
127
|
-
console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
|
|
128
|
-
console.log(
|
|
129
|
-
` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
// ignore
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
console.log("\nmem commands are now built into fapony:");
|
|
138
|
-
console.log(' fapony mem add <kind> "<text>" --files <files>');
|
|
139
|
-
console.log(' fapony mem close <id> "<text>"');
|
|
140
|
-
console.log(" fapony mem find <word>");
|
|
141
|
-
console.log(" fapony mem kickoff [id|spec.md]");
|
|
142
|
-
console.log(" fapony mem done | stale");
|
|
143
|
-
}
|