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.
Files changed (64) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -8
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +2 -11
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -211
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -400
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -258
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -394
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -792
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -284
  59. package/src/mem/engine.ts +0 -329
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/key-registry.ts +0 -116
  62. package/src/mem/render.ts +0 -66
  63. package/src/mem/selectors.ts +0 -227
  64. package/src/mem/store.ts +0 -356
package/src/mem/render.ts DELETED
@@ -1,66 +0,0 @@
1
- // render.ts — formatting and display helpers for the memory log
2
-
3
- import { claimsOf, openRows } from "./selectors.js";
4
- import type { ClaimRow, CloseRow, LogRow, WorkRow } from "./store.js";
5
-
6
- export const fmtRow = (r: WorkRow, claims?: Map<string, ClaimRow>) => {
7
- let s = `- [${r.id}]`;
8
- if (claims?.has(r.id)) s += ` (${claims.get(r.id)?.agent})`;
9
- s += ` ${r.kind}`;
10
- // key only when present — rows without one stay byte-identical
11
- if (r.key) s += ` #${r.key}`;
12
- s += ` ${r.text}`;
13
- if (r.spec) s += ` → ${r.spec}`;
14
- return s;
15
- };
16
-
17
- export const printOpenRows = (all: LogRow[], opts?: { showHold?: boolean }) => {
18
- const open = openRows(all);
19
- const claims = claimsOf(all);
20
-
21
- // in-progress (claimed items)
22
- const inProg = open.filter(
23
- (r) => claims.has(r.id) && (r.kind === "next" || r.kind === "bug"),
24
- );
25
- if (inProg.length) {
26
- console.log(
27
- `\n## in-progress\n${inProg.map((r) => fmtRow(r, claims)).join("\n")}`,
28
- );
29
- }
30
-
31
- // next (unclaimed only)
32
- const nexts = open.filter((r) => r.kind === "next" && !claims.has(r.id));
33
- if (nexts.length) {
34
- console.log(`\n## next\n${nexts.map((r) => fmtRow(r)).join("\n")}`);
35
- }
36
-
37
- // bug (unclaimed only)
38
- const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
39
- if (bugs.length) {
40
- console.log(`\n## bug\n${bugs.map((r) => fmtRow(r)).join("\n")}`);
41
- }
42
-
43
- // hold — sorted by spec for per-plan visibility
44
- if (opts?.showHold) {
45
- const holds = open
46
- .filter((r) => r.kind === "hold")
47
- .sort((a, b) => (a.spec ?? "").localeCompare(b.spec ?? ""));
48
- if (holds.length) {
49
- console.log(`\n## hold\n${holds.map((r) => fmtRow(r)).join("\n")}`);
50
- }
51
- }
52
- };
53
-
54
- export const fmtClose = (c: CloseRow, byId: Map<string, WorkRow>) => {
55
- const o = byId.get(c.ref);
56
- const agentStr = c.agent && c.agent !== "unknown" ? ` (${c.agent})` : "";
57
- return `- ${c.ts.slice(0, 10)}${agentStr} ${o && "text" in o ? o.text : c.ref} → ${c.text}`;
58
- };
59
-
60
- export const doneLines = (all: LogRow[], limit?: number) => {
61
- const workAll = all.filter((r): r is WorkRow => "id" in r);
62
- const byId = new Map(workAll.map((r) => [r.id, r] as const));
63
- const closes = all.filter((r): r is CloseRow => r.kind === "close").reverse();
64
- const slice = limit ? closes.slice(0, limit) : closes;
65
- return slice.map((c) => fmtClose(c, byId));
66
- };
@@ -1,227 +0,0 @@
1
- // selectors.ts — derive open/claimed/stale views from the append-only log
2
-
3
- import type {
4
- ClaimRow,
5
- CloseRow,
6
- LogRow,
7
- ReleaseRow,
8
- WorkRow,
9
- } from "./store.js";
10
-
11
- // ponytail: reducer = filter + tombstone set. 50k lines = 9.6MB/26ms — the file is not the bottleneck
12
- // the real ceiling = too many open rows for the view to make sense → rotate then: git mv + append openRows(old) into a new file
13
- export const openRows = (all: LogRow[]): WorkRow[] => {
14
- const dead = new Set(
15
- all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
16
- );
17
- return all.filter(
18
- (r): r is WorkRow =>
19
- r.kind !== "close" &&
20
- r.kind !== "claim" &&
21
- r.kind !== "release" &&
22
- r.kind !== "synced" &&
23
- "id" in r &&
24
- !dead.has(r.id),
25
- );
26
- };
27
-
28
- // Distinct keys on open work rows, sorted — the important-index ingredient
29
- // (PLAN-mem-keys chunk 3). A key is open while ≥1 keyed row survives the
30
- // tombstone set; computed live like every other selector, never persisted.
31
- // Decisions are skipped: nobody closes them, so a keyed decision would sit in
32
- // the index forever as a fake "open problem".
33
- export const openKeys = (all: LogRow[]): { key: string; open: number }[] => {
34
- const counts = new Map<string, number>();
35
- for (const r of openRows(all)) {
36
- if (r.key && r.kind !== "decision")
37
- counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
38
- }
39
- return [...counts.entries()]
40
- .map(([key, open]) => ({ key, open }))
41
- .sort((a, b) => a.key.localeCompare(b.key));
42
- };
43
-
44
- // C) claimsOf: returns Map ref → latest claim row still active
45
- // active = the latest claim|release row is a claim and ref is not in dead (close)
46
- export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
47
- const dead = new Set(
48
- all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
49
- );
50
- const latest = new Map<string, ClaimRow | ReleaseRow>();
51
- for (const r of all) {
52
- if (r.kind === "claim" || r.kind === "release") {
53
- latest.set(r.ref, r);
54
- }
55
- }
56
- const active = new Map<string, ClaimRow>();
57
- for (const [ref, r] of latest) {
58
- if (r.kind === "claim" && !dead.has(ref)) {
59
- active.set(ref, r);
60
- }
61
- }
62
- return active;
63
- };
64
-
65
- // a decision newer than both the spec's latest commit and the synced marker = spec not updated to match
66
- // + next/hold whose spec was edited after creation = may be done already but nobody closed it — real case: msbnndmt
67
- // + an active claim older than 4h = the agent may have died mid-task
68
- export const staleReport = (all: LogRow[]): string[] => {
69
- const out: string[] = [];
70
- const mark: Record<string, number> = {};
71
- for (const r of all) {
72
- if (r.kind === "synced") mark[r.spec] = Date.parse(r.ts);
73
- }
74
-
75
- // batch: collect unique spec paths, spawn one git per path
76
- const specPaths = new Set<string>();
77
- for (const r of all) {
78
- if (r.kind === "decision" && r.spec) specPaths.add(r.spec);
79
- }
80
- for (const r of openRows(all)) {
81
- if ((r.kind === "next" || r.kind === "hold") && r.spec)
82
- specPaths.add(r.spec);
83
- }
84
- const gitDates = new Map<string, number>();
85
- for (const spec of specPaths) {
86
- // --follow: a spec path that was git mv'd (e.g. moved into plan/done/) can still follow its history
87
- const git = Bun.spawnSync([
88
- "git",
89
- "log",
90
- "-1",
91
- "--follow",
92
- "--format=%cI",
93
- "--",
94
- spec,
95
- ])
96
- .stdout.toString()
97
- .trim();
98
- if (git) gitDates.set(spec, Date.parse(git));
99
- }
100
-
101
- for (const r of all) {
102
- if (r.kind === "decision" && r.spec) {
103
- const base = Math.max(gitDates.get(r.spec) ?? 0, mark[r.spec] ?? 0);
104
- if (base < Date.parse(r.ts))
105
- out.push(
106
- `STALE ${r.spec}: decision ${r.ts.slice(0, 10)} never made it into the spec — "${r.text}"`,
107
- );
108
- }
109
- }
110
- for (const r of openRows(all)) {
111
- if ((r.kind === "next" || r.kind === "hold") && r.spec) {
112
- const gitDate = gitDates.get(r.spec);
113
- // ponytail: 24h grace — "log next then write/commit the plan the same day" is normal workflow
114
- // not a signal that the work finished and was forgotten (the real case to catch, msbnndmt, is days apart) —
115
- // without grace = SUSPECT fires on every freshly written plan, and everyone learns to skim past the whole stale block
116
- if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
117
- out.push(
118
- `SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
119
- );
120
- }
121
- }
122
- const claims = claimsOf(all);
123
- for (const [ref, c] of claims) {
124
- const ageH = (Date.now() - Date.parse(c.ts)) / 3_600_000;
125
- if (ageH > 4) {
126
- out.push(
127
- `SUSPECT claim [${ref}] by ${c.agent} ${c.ts.slice(0, 10)} never closed/released — the agent may have died mid-task`,
128
- );
129
- }
130
- }
131
- return out;
132
- };
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
-
201
- // rotate: rows that must carry over into the new log file after archiving
202
- // - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
203
- // - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
204
- // spec was synced *after* this row = the info really made it into the spec, archive (git) is enough, no need to carry it in the hot file
205
- // (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
206
- // ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
207
- // attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
208
- export const rotateKeep = (all: LogRow[]): LogRow[] => {
209
- const open = openRows(all);
210
- const workOpen = open.filter(
211
- (r) => r.kind === "next" || r.kind === "bug" || r.kind === "hold",
212
- );
213
- const claims = claimsOf(all);
214
- const openIds = new Set(workOpen.map((r) => r.id));
215
- const keepClaims = [...claims.values()].filter((c) => openIds.has(c.ref));
216
-
217
- const syncedAt: Record<string, number> = {};
218
- for (const r of all)
219
- if (r.kind === "synced") syncedAt[r.spec] = Date.parse(r.ts);
220
- const keepDecisionNote = open.filter(
221
- (r) =>
222
- (r.kind === "decision" || r.kind === "note") &&
223
- (!r.spec || !(r.spec in syncedAt) || syncedAt[r.spec] < Date.parse(r.ts)),
224
- );
225
-
226
- return [...workOpen, ...keepDecisionNote, ...keepClaims];
227
- };
package/src/mem/store.ts DELETED
@@ -1,356 +0,0 @@
1
- // store.ts — types + config + read/write primitives for the append-only memory log
2
- //
3
- // Moved from templates/mem/store.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
4
- // The template was copied into every repo via `fapony init-mem`; now fapony owns the code
5
- // and calls it directly via `fapony mem <sub>`. Path resolution is no longer based on
6
- // import.meta.dir — initStore() receives the worktree explicitly from the CLI dispatch.
7
-
8
- import { createHash } from "node:crypto";
9
- import {
10
- appendFileSync,
11
- existsSync,
12
- mkdirSync,
13
- readdirSync,
14
- readFileSync,
15
- } from "node:fs";
16
- import { hostname, networkInterfaces } from "node:os";
17
- import { basename, dirname, join, relative } from "node:path";
18
- import {
19
- CONFIG_FILENAME,
20
- DEFAULT_MEM_DIR,
21
- FAPONY_DIR,
22
- } from "../core/config.js";
23
- import { whereMemDir } from "../memory.js";
24
-
25
- // --- types ---
26
-
27
- type WorkKind = "next" | "bug" | "decision" | "note" | "hold";
28
-
29
- type WorkRow = {
30
- ts: string;
31
- agent: string;
32
- id: string;
33
- kind: WorkKind;
34
- text: string;
35
- spec?: string;
36
- files?: string[];
37
- key?: string;
38
- v?: number;
39
- };
40
-
41
- type CloseRow = {
42
- ts: string;
43
- agent: string;
44
- kind: "close";
45
- ref: string;
46
- text: string;
47
- };
48
-
49
- type ClaimRow = {
50
- ts: string;
51
- agent: string;
52
- kind: "claim";
53
- ref: string;
54
- };
55
-
56
- type ReleaseRow = {
57
- ts: string;
58
- agent: string;
59
- kind: "release";
60
- ref: string;
61
- text?: string;
62
- };
63
-
64
- type SyncedRow = {
65
- ts: string;
66
- agent: string;
67
- kind: "synced";
68
- spec: string;
69
- };
70
-
71
- type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
72
-
73
- // --- identity ---
74
- //
75
- // `agent` goes in every row, `person` names the file. Both used to end in
76
- // "unknown", which is not a fallback: two machines with no git and no $USER
77
- // wrote the same filename and their rows interleaved with no way to tell them
78
- // apart. Generic OS accounts collide the same way — `admin`/`user`/`owner` are
79
- // what a fresh install offers, so treat them as absent rather than as a name.
80
- // git user.email is unique by construction; the machine tag is the last resort,
81
- // hashed because this file is committed and a raw MAC address is not ours to
82
- // publish. user.email is only spawned when the cheap sources fail — initStore
83
- // sits on the mem_add hot path (git config ≈ 12ms).
84
-
85
- const GENERIC_NAMES = new Set([
86
- "admin",
87
- "user",
88
- "owner",
89
- "root",
90
- "ubuntu",
91
- "ec2-user",
92
- "vagrant",
93
- "dev",
94
- "developer",
95
- "node",
96
- "unknown",
97
- ]);
98
-
99
- const usable = (v: string | null | undefined): string | null => {
100
- const s = (v ?? "").trim();
101
- return s && !GENERIC_NAMES.has(s.toLowerCase()) ? s : null;
102
- };
103
-
104
- // `env: process.env` is not redundant: Bun.spawnSync snapshots the environment
105
- // at startup, so a variable set at runtime (GIT_CONFIG_GLOBAL in a test, MEM_*
106
- // set by a wrapper) never reaches the child without passing it explicitly.
107
- const gitConfig = (key: string, cwd: string): string | null =>
108
- Bun.spawnSync(["git", "config", key], { cwd, env: process.env })
109
- .stdout.toString()
110
- .trim() || null;
111
-
112
- function machineSeed(): string {
113
- try {
114
- const id = readFileSync("/etc/machine-id", "utf8").trim();
115
- if (id) return id;
116
- } catch {
117
- // no /etc/machine-id (macOS) — fall through to the interface list
118
- }
119
- for (const ifaces of Object.values(networkInterfaces()))
120
- for (const i of ifaces ?? [])
121
- if (!i.internal && i.mac && i.mac !== "00:00:00:00:00:00") return i.mac;
122
- return hostname();
123
- }
124
-
125
- const machineTag = (): string =>
126
- `m-${createHash("sha256").update(machineSeed()).digest("hex").slice(0, 8)}`;
127
-
128
- const slug = (v: string): string =>
129
- v
130
- .toLowerCase()
131
- .replace(/[^a-z0-9._-]+/g, "-")
132
- .replace(/^-+|-+$/g, "");
133
-
134
- // --- mutable state (populated by initStore) ---
135
-
136
- let root = "";
137
- let dir = "";
138
- let planDir = "";
139
- let doneDir = "";
140
- let agent = "";
141
- let person = "";
142
- let LOG = "";
143
- let memCmd = "";
144
- let planBase = "";
145
- let app = "";
146
- // Non-empty when the resolver found two or more app-scoped mem dirs and no
147
- // single one at/above cwd — a write must die rather than start a third log.
148
- let blockWrite = "";
149
-
150
- // --- init ---
151
-
152
- /**
153
- * Initialize the store for a given worktree. Must be called before using any
154
- * export. The CLI dispatch layer calls this with the resolved worktree path.
155
- *
156
- * `overrideMemDir` (from --mem-dir flag) skips resolution entirely.
157
- * Otherwise whereMemDir() walks up from cwd — the same dir the reader
158
- * (src/memory.ts) uses, so writer and reader cannot drift apart.
159
- */
160
- export function initStore(worktree: string, overrideMemDir?: string): void {
161
- root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
162
- cwd: worktree,
163
- })
164
- .stdout.toString()
165
- .trim();
166
- if (!root) root = worktree;
167
-
168
- // An explicit --mem-dir is a promise: never silently fall back to the
169
- // default when the path is wrong, or the row lands in a log nobody reads.
170
- if (overrideMemDir && !existsSync(overrideMemDir)) {
171
- throw new Error(`--mem-dir path does not exist: ${overrideMemDir}`);
172
- }
173
-
174
- // Resolution and the CLI's `mem where` share one function, so the path a row
175
- // is written to is the path `mem where` reports.
176
- blockWrite = "";
177
- const resolved = whereMemDir(worktree, overrideMemDir);
178
- if (resolved.step === "ambiguous") {
179
- const list = (resolved.candidates ?? []).map((c) => ` ${c}`).join("\n");
180
- blockWrite =
181
- `two or more .fapony/.memory dirs under ${root} — refusing to guess:\n` +
182
- `${list}\ncd into one, set paths.memDir, or pass --mem-dir`;
183
- }
184
- dir = resolved.dir ?? join(root, DEFAULT_MEM_DIR);
185
-
186
- // planBase: the .fapony dir — plan/done/conventions live here.
187
- // Derive from dir by going up from .fapony/.memory → .fapony
188
- planBase = dirname(dir);
189
-
190
- // app name for display — the directory containing .fapony
191
- app = dirname(planBase);
192
-
193
- // Read fapony.config.json
194
- const configDir =
195
- planBase === `${root}/${FAPONY_DIR}` ? root : dirname(planBase);
196
- const configPaths = ((): Record<string, string> => {
197
- try {
198
- const raw = readFileSync(`${configDir}/${CONFIG_FILENAME}`, "utf8");
199
- return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
200
- } catch {
201
- return {};
202
- }
203
- })();
204
- const fromConfig = (key: string): string | null =>
205
- typeof configPaths[key] === "string"
206
- ? join(configDir, configPaths[key])
207
- : null;
208
-
209
- planDir = `${planBase}/plan`;
210
-
211
- // done/ sits beside plan/ (same depth, relative links survive)
212
- doneDir =
213
- fromConfig("doneDir") ??
214
- (!existsSync(`${planBase}/done`) && existsSync(`${planDir}/done`)
215
- ? `${planDir}/done`
216
- : `${planBase}/done`);
217
-
218
- let emailCache: string | null | undefined;
219
- const email = (): string | null =>
220
- emailCache !== undefined
221
- ? emailCache
222
- : (emailCache = gitConfig("user.email", root));
223
-
224
- agent =
225
- process.env.MEM_AGENT?.trim() ||
226
- usable(process.env.USER) ||
227
- usable(email()) ||
228
- machineTag();
229
-
230
- // filename = *person*, not client — naming by MEM_AGENT would collide
231
- person = slug(
232
- usable(gitConfig("user.name", root)) ||
233
- usable(process.env.USER) ||
234
- usable(email()) ||
235
- machineTag(),
236
- );
237
-
238
- LOG = `${dir}/log.${person}.jsonl`;
239
- memCmd = `fapony mem`;
240
- }
241
-
242
- // --- log file helpers ---
243
-
244
- const isLogFile = (f: string): boolean =>
245
- f === "log.jsonl" ||
246
- (/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
247
- !/^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
248
-
249
- const listLogs = (match: (f: string) => boolean): string[] =>
250
- existsSync(dir)
251
- ? readdirSync(dir)
252
- .filter(match)
253
- .sort()
254
- .map((f) => join(dir, f))
255
- : [];
256
-
257
- const logFiles = (): string[] => listLogs(isLogFile);
258
-
259
- const archivedFiles = (): string[] =>
260
- listLogs((f) => /^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
261
-
262
- // --- core ---
263
-
264
- const parseLog = (f: string): LogRow[] =>
265
- readFileSync(f, "utf8")
266
- .split("\n")
267
- .filter(Boolean)
268
- .flatMap((l: string, i: number) => {
269
- try {
270
- return [JSON.parse(l) as LogRow];
271
- } catch {
272
- console.error(`[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`);
273
- return [];
274
- }
275
- });
276
-
277
- const byTs = (a: LogRow, b: LogRow) => a.ts.localeCompare(b.ts);
278
-
279
- // The live view: what `rotate` counts and what open-work commands read. It must
280
- // stay archive-free or rotate re-imports the rows it just moved out and never
281
- // gets under its own threshold.
282
- const rows = (): LogRow[] => logFiles().flatMap(parseLog).sort(byTs);
283
-
284
- // The recall view: rotated rows included. A bug closed six months ago is exactly
285
- // what `find` exists to surface — rotate shrinks the live file, it does not
286
- // decide what is still worth remembering.
287
- const allRows = (): LogRow[] =>
288
- [...logFiles(), ...archivedFiles()].flatMap(parseLog).sort(byTs);
289
-
290
- function put(r: Omit<WorkRow, "ts" | "agent">): void;
291
- function put(r: Omit<CloseRow, "ts" | "agent">): void;
292
- function put(r: Omit<ClaimRow, "ts" | "agent">): void;
293
- function put(r: Omit<ReleaseRow, "ts" | "agent">): void;
294
- function put(r: Omit<SyncedRow, "ts" | "agent">): void;
295
- function put(
296
- r:
297
- | Omit<WorkRow, "ts" | "agent">
298
- | Omit<CloseRow, "ts" | "agent">
299
- | Omit<ClaimRow, "ts" | "agent">
300
- | Omit<ReleaseRow, "ts" | "agent">
301
- | Omit<SyncedRow, "ts" | "agent">,
302
- ) {
303
- if (blockWrite) throw new Error(blockWrite);
304
- mkdirSync(dir, { recursive: true });
305
- appendFileSync(
306
- LOG,
307
- `${JSON.stringify({ ts: new Date().toISOString(), agent, ...r })}\n`,
308
- );
309
- }
310
-
311
- function nextId(all: LogRow[]): string {
312
- const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
313
- let base = Date.now();
314
- let id = base.toString(36);
315
- while (used.has(id)) {
316
- id = base.toString(36) + Math.random().toString(36).slice(2, 4);
317
- base++;
318
- }
319
- return id;
320
- }
321
-
322
- const appendRaw = (path: string, r: LogRow): void =>
323
- appendFileSync(path, `${JSON.stringify(r)}\n`);
324
-
325
- const rel = (p: string) => relative(root, p) || ".";
326
-
327
- const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
328
-
329
- export type {
330
- ClaimRow,
331
- CloseRow,
332
- LogRow,
333
- ReleaseRow,
334
- SyncedRow,
335
- WorkKind,
336
- WorkRow,
337
- };
338
- export {
339
- agent,
340
- allRows,
341
- app,
342
- appendRaw,
343
- dir,
344
- doneDir,
345
- KINDS,
346
- LOG,
347
- memCmd,
348
- nextId,
349
- person,
350
- planBase,
351
- planDir,
352
- put,
353
- rel,
354
- root,
355
- rows,
356
- };