fapony 0.1.3 → 0.2.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 (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
@@ -1,285 +0,0 @@
1
- // store.ts — types + config + read/write primitives for the append-only memory log
2
-
3
- import {
4
- appendFileSync,
5
- existsSync,
6
- mkdirSync,
7
- readdirSync,
8
- readFileSync,
9
- } from "node:fs";
10
- import { basename, dirname, join, relative } from "node:path";
11
-
12
- // --- types ---
13
-
14
- type WorkKind = "next" | "bug" | "decision" | "note" | "hold";
15
-
16
- type WorkRow = {
17
- ts: string;
18
- agent: string;
19
- id: string;
20
- kind: WorkKind;
21
- text: string;
22
- spec?: string;
23
- files?: string[];
24
- };
25
-
26
- type CloseRow = {
27
- ts: string;
28
- agent: string;
29
- kind: "close";
30
- ref: string;
31
- text: string;
32
- };
33
-
34
- type ClaimRow = {
35
- ts: string;
36
- agent: string;
37
- kind: "claim";
38
- ref: string;
39
- };
40
-
41
- type ReleaseRow = {
42
- ts: string;
43
- agent: string;
44
- kind: "release";
45
- ref: string;
46
- text?: string;
47
- };
48
-
49
- type SyncedRow = {
50
- ts: string;
51
- agent: string;
52
- kind: "synced";
53
- spec: string;
54
- };
55
-
56
- type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
57
-
58
- // --- config ---
59
-
60
- const root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"])
61
- .stdout.toString()
62
- .trim();
63
- // ponytail: worktree named wt-<app> = monorepo scope (apps/<app>/.fapony/.memory).
64
- // fapony template: single repo (no apps/) → fallback straight to .fapony/.memory at root
65
- // no config/flag needed for either shape
66
- const app = process.env.MEM_APP ?? basename(root).replace(/^wt-/, "");
67
- // app container folder found from {apps,packages,services}/<app>, first that exists — fixed order
68
- // apps → packages → services, first hit wins (the order is the contract, not an accident)
69
- // the `unknown app` guard below is still tied to apps/ as before — not widened this round
70
- const appBase: string | undefined = ["apps", "packages", "services"]
71
- .map((d) => `${root}/${d}/${app}`)
72
- .find((p) => existsSync(p));
73
- const monorepo = appBase !== undefined;
74
-
75
- // The copy `fapony init` places lives in <project>/.fapony/.memory/ — its log and plan
76
- // must key off their own folder, not the git root: the real broken case is apps/<x>/.fapony/.memory/ in a monorepo,
77
- // where the heuristic below would point at apps/<worktree name>/.fapony/.memory = writing a log mixed into another project.
78
- // But the central copy that was moved into .fapony/.memory at the monorepo root itself (single code copy, logs split per
79
- // app — e.g. vela) must "not" count as scaffolded even though the path matches, because it still has to guess the app
80
- // from the monorepo — the discriminator is "can the app be guessed" (`monorepo`), not "is there an app container folder":
81
- // just having apps/ at the root does not make this copy the central one — `fapony init <monorepo root>` also places
82
- // .fapony/.memory at the root, and it must key off its own folder, or it dies at the guard below
83
- // from the very first command even though its plan sits right next to it.
84
- const centralAtMonorepoRoot =
85
- import.meta.dir === `${root}/.fapony/.memory` && monorepo;
86
- const scaffolded =
87
- import.meta.dir.includes("/.fapony/.memory") && !centralAtMonorepoRoot;
88
-
89
- // apps/ exists but apps/<app> does not = guessed the app wrong (worktree name mismatch / typo in MEM_APP) — die here
90
- // rather than fall back silently and write a log at the root that becomes an orphan nobody reads
91
- // (a copy scaffolded under apps/<x>/.fapony/.memory/ keys off its own folder, needs no guess, so it does not hit this)
92
- if (!scaffolded && !monorepo && existsSync(`${root}/apps`)) {
93
- console.error(
94
- `unknown app (guessed "${app}" from ${basename(root)}) — pass MEM_APP=<app>`,
95
- );
96
- process.exit(1);
97
- }
98
- // new default: log lives under .fapony/.memory — fall back to the old .memory/ only when an old log actually exists
99
- // check for log.jsonl, not the dir: an empty folder someone accidentally mkdir'd must not lock the repo to the old layout
100
- const newDir = appBase
101
- ? `${appBase}/.fapony/.memory`
102
- : `${root}/.fapony/.memory`;
103
- const legacyDir = appBase ? `${appBase}/.memory` : `${root}/.memory`;
104
- const dir = scaffolded
105
- ? import.meta.dir
106
- : existsSync(`${legacyDir}/log.jsonl`)
107
- ? legacyDir
108
- : newDir;
109
- // who wrote this row — the client can override with MEM_AGENT ("claude-code", "opencode")
110
- const agent = process.env.MEM_AGENT || process.env.USER || "unknown";
111
-
112
- // filename = *person*, not client, deliberately different from agent above: naming files by MEM_AGENT
113
- // would put two people running Claude Code back into the same log.claude-code.jsonl = same collision as before
114
- // git user.name exists on every machine that can commit, so no env to set and no config to add
115
- const person = (
116
- Bun.spawnSync(["git", "config", "user.name"]).stdout.toString().trim() ||
117
- process.env.USER ||
118
- "unknown"
119
- )
120
- .toLowerCase()
121
- .replace(/[^a-z0-9._-]+/g, "-")
122
- .replace(/^-+|-+$/g, "");
123
-
124
- // write your own file, read everyone's — two people never touch the same file = merge conflicts
125
- // are structurally zero, no need for merge=union or GitHub behaving itself on PR merge
126
- const LOG = `${dir}/log.${person || "unknown"}.jsonl`;
127
-
128
- // log.jsonl = the pre-split original (still read forever, no migration needed)
129
- // skip the log.YYYY-MM-DD.jsonl that rotate creates, or rotate reduces nothing because they get read back in
130
- const isLogFile = (f: string): boolean =>
131
- f === "log.jsonl" ||
132
- (/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
133
- !/^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
134
-
135
- const logFiles = (): string[] =>
136
- existsSync(dir)
137
- ? readdirSync(dir)
138
- .filter(isLogFile)
139
- .sort()
140
- .map((f) => join(dir, f))
141
- : [];
142
-
143
- // the folder this project's plan/ and done/ live under — the single place these paths are assembled
144
- // (previously commands/plan.ts hardcoded `apps/<app>/plan` in 10 places = dead on arrival for a single repo)
145
- const planBase = scaffolded
146
- ? dirname(import.meta.dir) // <project>/.fapony
147
- : (appBase ?? root);
148
-
149
- // fapony.config.json is the *agreement* on where plan lives; planBase above is only a guess —
150
- // whenever the file exists it must beat the guess (vela declares `apps/vela/plan` outright, which happens to match the guess
151
- // but a repo that puts plan elsewhere breaks silently if this is not read)
152
- // read only at the project level: a copy scaffolded in apps/<x>/.fapony/ must not pick up the root
153
- // monorepo's config, because that is another project's path
154
- const configDir = scaffolded ? dirname(planBase) : root;
155
-
156
- const configPaths = ((): Record<string, string> => {
157
- try {
158
- const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
159
- return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
160
- } catch {
161
- // no file / broken JSON → use the guessed values, not an error: memory must work without fapony
162
- return {};
163
- }
164
- })();
165
-
166
- const fromConfig = (key: string): string | null =>
167
- typeof configPaths[key] === "string"
168
- ? join(configDir, configPaths[key])
169
- : null;
170
-
171
- // an app that moved plan into .fapony/ uses the new location; one that has not uses the old — so a monorepo migrates app by app
172
- // without touching config (config has a single planDir, so declaring it points the other apps wrong too)
173
- const base = existsSync(`${planBase}/.fapony`)
174
- ? `${planBase}/.fapony`
175
- : planBase;
176
-
177
- const planDir = fromConfig("planDir") ?? `${base}/plan`;
178
-
179
- // done/ sits beside plan/ (same depth after the move, relative links in files survive) — a repo still on the old layout
180
- // with plan/done/ keeps using it, no need to move before sweeping
181
- const doneDir =
182
- fromConfig("doneDir") ??
183
- (!existsSync(`${base}/done`) && existsSync(`${planDir}/done`)
184
- ? `${planDir}/done`
185
- : `${base}/done`);
186
-
187
- // path used for display/logging — always relative to repo root (`apps/vela/plan`, `.fapony/plan`)
188
- const rel = (p: string) => relative(root, p) || ".";
189
-
190
- // the command we tell the user to type must be the path of the mem.ts actually running, not a constant —
191
- // the copy `fapony init` places lives in .fapony/.memory/, not the .memory/ the old help text hardcoded
192
- const memCmd = `bun ${rel(dir)}/mem.ts`;
193
-
194
- const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
195
-
196
- // --- core ---
197
-
198
- const rows = (): LogRow[] =>
199
- logFiles()
200
- .flatMap((f) =>
201
- readFileSync(f, "utf8")
202
- .split("\n")
203
- .filter(Boolean)
204
- .flatMap((l: string, i: number) => {
205
- try {
206
- return [JSON.parse(l) as LogRow];
207
- } catch {
208
- // ponytail: one broken line (bad escape) must not make the whole log unreadable — skip it and warn
209
- console.error(
210
- `[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`,
211
- );
212
- return [];
213
- }
214
- }),
215
- )
216
- // concatenating several files scrambles the time order — every selector reads top-down assuming ts order
217
- .sort((a, b) => a.ts.localeCompare(b.ts));
218
-
219
- function put(r: Omit<WorkRow, "ts" | "agent">): void;
220
- function put(r: Omit<CloseRow, "ts" | "agent">): void;
221
- function put(r: Omit<ClaimRow, "ts" | "agent">): void;
222
- function put(r: Omit<ReleaseRow, "ts" | "agent">): void;
223
- function put(r: Omit<SyncedRow, "ts" | "agent">): void;
224
- function put(
225
- r:
226
- | Omit<WorkRow, "ts" | "agent">
227
- | Omit<CloseRow, "ts" | "agent">
228
- | Omit<ClaimRow, "ts" | "agent">
229
- | Omit<ReleaseRow, "ts" | "agent">
230
- | Omit<SyncedRow, "ts" | "agent">,
231
- ) {
232
- mkdirSync(dir, { recursive: true });
233
- appendFileSync(
234
- LOG,
235
- `${JSON.stringify({ ts: new Date().toISOString(), agent, ...r })}\n`,
236
- );
237
- }
238
-
239
- // ponytail: prevent id collisions — base36 + increment per retry + random suffix
240
- // shared everywhere a WorkRow.id must be generated (cmdAdd, ship-log in cmdPlanSweep)
241
- // do not copy this loop elsewhere — change the scheme here only
242
- function nextId(all: LogRow[]): string {
243
- const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
244
- let base = Date.now();
245
- let id = base.toString(36);
246
- while (used.has(id)) {
247
- id = base.toString(36) + Math.random().toString(36).slice(2, 4);
248
- base++;
249
- }
250
- return id;
251
- }
252
-
253
- // write a raw row (original ts/agent, no regeneration) — used when rotate moves old rows to a new file
254
- // normally writing to the log must go through put(); this is the only exception
255
- const appendRaw = (path: string, r: LogRow): void =>
256
- appendFileSync(path, `${JSON.stringify(r)}\n`);
257
-
258
- export type {
259
- ClaimRow,
260
- CloseRow,
261
- LogRow,
262
- ReleaseRow,
263
- SyncedRow,
264
- WorkKind,
265
- WorkRow,
266
- };
267
- export {
268
- agent,
269
- app,
270
- appendRaw,
271
- configDir,
272
- dir,
273
- doneDir,
274
- KINDS,
275
- LOG,
276
- memCmd,
277
- nextId,
278
- person,
279
- planBase,
280
- planDir,
281
- put,
282
- rel,
283
- root,
284
- rows,
285
- };
File without changes
File without changes
File without changes
File without changes