fapony 0.6.2 → 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 (63) 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 -6
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +1 -2
  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 -139
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -410
  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 -252
  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 -375
  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 -738
  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 -273
  59. package/src/mem/engine.ts +0 -315
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/render.ts +0 -66
  62. package/src/mem/selectors.ts +0 -160
  63. package/src/mem/store.ts +0 -356
package/src/mem/index.ts DELETED
@@ -1,201 +0,0 @@
1
- // src/mem/index.ts — CLI dispatch for `fapony mem <sub>`
2
- //
3
- // Moved from templates/mem/mem.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
4
- // Now an export function called by fapony.ts, not a standalone script.
5
-
6
- import { existsSync } from "node:fs";
7
- import { basename, join } from "node:path";
8
- import { levenshtein } from "../commands.js";
9
- import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
10
- import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
11
- import { cmdRotate } from "./commands/rotate.js";
12
- import { cmdWhere } from "./commands/where.js";
13
- import {
14
- cmdAdd,
15
- cmdClaim,
16
- cmdClose,
17
- cmdHook,
18
- cmdRelease,
19
- cmdSynced,
20
- } from "./commands/write.js";
21
- import { doneDir, planDir } from "./store.js";
22
-
23
- const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
24
-
25
- subcommands:
26
- where show the resolved mem dir and which step won
27
- kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
28
- add <kind> "<text>" --files f1,f2 [--key k] [spec.md]
29
- close <id> "<msg>" close a bug
30
- find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
31
- substring-search every row (archives included)
32
- done | stale views
33
- claim <id> | release <id> | synced bookkeeping
34
- plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
35
- plan-check <plan.md> validate a plan's frontmatter/sections
36
- rotate archive the live log when it grows
37
- hook Stop-hook payload reader (installed, not run by hand)
38
-
39
- --mem-dir <path> wins over config and the walk-up (global to every sub)
40
- example: fapony mem add note "decided X because Y" --files src/a.ts`;
41
-
42
- const HELP: Record<string, string> = {
43
- where: `usage: fapony mem where [--from <dir>]
44
- show the .fapony/.memory dir the resolver picks and which step won — writes nothing
45
- --from <dir> resolve as if cwd were here
46
- example: fapony mem where`,
47
- kickoff: `usage: fapony mem kickoff [<plan.md>] [--pick <n>]
48
- open a session and print a "next up" list (priority plans, unchecked chunks, open bugs)
49
- <plan.md> plan whose first unchecked chunk is offered as a context line
50
- --pick <n> run suggestion [n] non-interactively; refuses context/template items
51
- example: fapony mem kickoff .fapony/plan/PLAN-x.md`,
52
- add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [--key k] [spec.md]
53
- append one row to the mem log
54
- --files f1,f2 repo-relative paths this row is about (required)
55
- --key k problem identity, [a-z0-9-]{3,40} — same problem = same key (optional)
56
- --stdin read <text> from stdin (avoids shell metachar)
57
- example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts --key unify-mem-engine`,
58
- close: `usage: fapony mem close <id> "<what was done | commit>"
59
- tombstone a bug so it stops showing as open work
60
- --stdin read the message from stdin
61
- example: fapony mem close mt14 "fixed in a2c6beb"`,
62
- find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
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
- --kind a,b include only these kinds (overrides the bookkeeping default)
65
- --files f1,f2 rows about these paths (stored files[] first, text/spec/ref fallback)
66
- --key k exact problem-identity match — a miss prints the known keys
67
- --since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
68
- --limit n max rows returned (default 20, newest first)
69
- example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
70
- done: `usage: fapony mem done
71
- closed rows with their tombstone message
72
- example: fapony mem done`,
73
- stale: `usage: fapony mem stale
74
- decisions that never made it into their spec
75
- example: fapony mem stale`,
76
- claim: `usage: fapony mem claim <id>
77
- claim an open next|bug before working it
78
- example: fapony mem claim mt14`,
79
- release: `usage: fapony mem release <id> [--stdin]
80
- give back a claimed row without closing it
81
- example: fapony mem release mt14`,
82
- synced: `usage: fapony mem synced [<spec.md>]
83
- mark a spec as carried into the log
84
- example: fapony mem synced .fapony/spec/SPEC-x.md`,
85
- "plan-sweep": `usage: fapony mem plan-sweep [<plan.md>] [--apply]
86
- find shipped plans (or move one) and fix the links that point at it
87
- --apply perform the git mv + link fixes (default = dry run)
88
- example: fapony mem plan-sweep .fapony/plan/PLAN-x.md --apply`,
89
- "plan-check": `usage: fapony mem plan-check <plan.md>
90
- validate a plan's frontmatter and required sections
91
- example: fapony mem plan-check .fapony/plan/PLAN-x.md`,
92
- rotate: `usage: fapony mem rotate
93
- archive the live log to log.YYYY-MM-DD.jsonl once it crosses the threshold
94
- example: fapony mem rotate`,
95
- hook: `usage: fapony mem hook
96
- reads the Stop-hook payload on stdin — installed by fapony, not run by hand
97
- example: (installed hook only)`,
98
- };
99
-
100
- // Subcommand names for top-level did-you-mean (src/commands.ts) — derived
101
- // from HELP keys so the list cannot drift from the dispatch below.
102
- export const MEM_SUBCOMMANDS: string[] = Object.keys(HELP);
103
-
104
- // Chunk 7 (PLAN-seed-and-surface, bug muc85pml): `fapony mem now` used to fall
105
- // through to kickoff and print an overview ("N entries") instead of an error —
106
- // a typo that looks like success. Three rules, in order:
107
- // 1. a known subcommand → dispatch (unchanged)
108
- // 2. looks like a plan path (.md suffix, a slash, or a file with that name
109
- // in planDir/doneDir) → kickoff — this is `fapony mem PLAN-x.md`, which
110
- // must keep working without the `kickoff` word
111
- // 3. anything else → error, exit 1 (a typo is never a kickoff)
112
- //
113
- // `exists` is injectable so tests hit this directly without a store on disk —
114
- // production passes nothing and reads planDir/doneDir live.
115
- export function classifyMemArg(
116
- arg: string,
117
- exists?: (name: string) => boolean,
118
- ): "subcommand" | "plan" | "unknown" {
119
- // hasOwn, not `in` or indexing — prototype props ("toString") are not
120
- // subcommands.
121
- if (Object.hasOwn(HELP, arg)) return "subcommand";
122
- if (arg.endsWith(".md") || arg.includes("/")) return "plan";
123
- const has =
124
- exists ??
125
- ((name: string): boolean => {
126
- const base = basename(name);
127
- const candidates = [name, base, `${name}.md`, `${base}.md`];
128
- return candidates.some(
129
- (c) => existsSync(join(planDir, c)) || existsSync(join(doneDir, c)),
130
- );
131
- });
132
- if (has(arg)) return "plan";
133
- return "unknown";
134
- }
135
-
136
- export async function cmdMem(a: string[], memDir?: string): Promise<void> {
137
- const [cmd, ...rest] = a;
138
-
139
- // SPEC §2: every subcommand answers --help with signature + flags + one runnable
140
- // example, because the reader is an agent that gets one call to get it right.
141
- if (cmd === "-h" || cmd === "--help") {
142
- console.log(MEM_HELP);
143
- return;
144
- }
145
- if (rest.includes("-h") || rest.includes("--help")) {
146
- console.log(HELP[cmd] ?? MEM_HELP);
147
- return;
148
- }
149
-
150
- if (cmd === "add") {
151
- await cmdAdd(rest);
152
- } else if (cmd === "close") {
153
- await cmdClose(rest);
154
- } else if (cmd === "claim") {
155
- cmdClaim(rest);
156
- } else if (cmd === "release") {
157
- await cmdRelease(rest);
158
- } else if (cmd === "synced") {
159
- cmdSynced(rest);
160
- } else if (cmd === "hook") {
161
- await cmdHook();
162
- } else if (cmd === "done") {
163
- cmdDone();
164
- } else if (cmd === "stale") {
165
- cmdStale();
166
- } else if (cmd === "find") {
167
- cmdFind(rest);
168
- } else if (cmd === "kickoff") {
169
- cmdKickoff(rest);
170
- } else if (cmd === "where") {
171
- cmdWhere(rest, memDir);
172
- } else if (cmd === "plan-sweep") {
173
- cmdPlanSweep(rest);
174
- } else if (cmd === "plan-check") {
175
- cmdPlanCheck(rest);
176
- } else if (cmd === "rotate") {
177
- cmdRotate(rest);
178
- } else if (cmd === undefined) {
179
- // bare `fapony mem` → kickoff (ranked session overview)
180
- cmdKickoff(rest);
181
- } else if (classifyMemArg(cmd) === "plan") {
182
- // `fapony mem PLAN-x.md` — the plan arg rides along (the old else-branch
183
- // dropped it and printed the no-arg overview instead).
184
- cmdKickoff([cmd, ...rest]);
185
- } else {
186
- console.error(`fapony mem: unknown subcommand "${cmd}"`);
187
- const scored = MEM_SUBCOMMANDS.map((s) => ({
188
- s,
189
- d: levenshtein(cmd, s),
190
- }))
191
- .filter((x) => x.d <= 2)
192
- .sort((x, y) => x.d - y.d || (x.s < y.s ? -1 : 1))
193
- .slice(0, 3)
194
- .map((x) => `"fapony mem ${x.s}"`);
195
- if (scored.length > 0) {
196
- console.error(`did you mean ${scored.join(" or ")}?`);
197
- }
198
- console.error(`subcommands: ${MEM_SUBCOMMANDS.join(" ")}`);
199
- process.exit(1);
200
- }
201
- }
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,160 +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
- // rotate: rows that must carry over into the new log file after archiving
135
- // - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
136
- // - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
137
- // 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
138
- // (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
139
- // ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
140
- // attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
141
- export const rotateKeep = (all: LogRow[]): LogRow[] => {
142
- const open = openRows(all);
143
- const workOpen = open.filter(
144
- (r) => r.kind === "next" || r.kind === "bug" || r.kind === "hold",
145
- );
146
- const claims = claimsOf(all);
147
- const openIds = new Set(workOpen.map((r) => r.id));
148
- const keepClaims = [...claims.values()].filter((c) => openIds.has(c.ref));
149
-
150
- const syncedAt: Record<string, number> = {};
151
- for (const r of all)
152
- if (r.kind === "synced") syncedAt[r.spec] = Date.parse(r.ts);
153
- const keepDecisionNote = open.filter(
154
- (r) =>
155
- (r.kind === "decision" || r.kind === "note") &&
156
- (!r.spec || !(r.spec in syncedAt) || syncedAt[r.spec] < Date.parse(r.ts)),
157
- );
158
-
159
- return [...workOpen, ...keepDecisionNote, ...keepClaims];
160
- };