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/engine.ts DELETED
@@ -1,329 +0,0 @@
1
- // src/mem/engine.ts — the single validation+write engine for mem add/close.
2
- //
3
- // Both entry points call it: CLI (commands/write.ts cmdAdd/cmdClose) and MCP
4
- // (adapters/mcp/tools/mem.ts memAdd/memClose). Every domain rule lives here
5
- // exactly once — kind check, files[] required, hold-requires-spec, open
6
- // next/hold caps. Wrappers own only their surface: argv parsing +
7
- // console.error/exit (CLI), worktree-shape checks + JSON results (MCP).
8
- //
9
- // Precondition: the store is initialized (initStore) before calling — cli.ts
10
- // does it at dispatch, the MCP wrapper does it per call with its worktree.
11
- // (PLAN-unify-mem-engine chunk 1)
12
-
13
- import { KEY_RE } from "../core/mem-log.js";
14
- import { checkKeyDomain, keyMatchesQuery } from "./key-registry.js";
15
- import { openRows } from "./selectors.js";
16
- import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
17
-
18
- export const CAP_NEXT = 15;
19
- export const CAP_HOLD = 10;
20
-
21
- /**
22
- * Thrown when an open next/hold cap is full. Carries which cap so the CLI
23
- * wrapper can append its MEM_FORCE hint while MCP passes the bare message
24
- * (the two surfaces keep their exact legacy strings — PLAN §4).
25
- */
26
- export class CapError extends Error {
27
- readonly cap: "next" | "hold";
28
- constructor(cap: "next" | "hold", message: string) {
29
- super(message);
30
- this.name = "CapError";
31
- this.cap = cap;
32
- }
33
- }
34
-
35
- export interface EngineAddArgs {
36
- kind: string;
37
- text: string;
38
- files: string[];
39
- spec?: string;
40
- /** Problem identity — optional, but validated against KEY_RE whenever present. */
41
- key?: string;
42
- /**
43
- * Registry domains for this repo (`.fapony/keys.json` via loadKeyRegistry).
44
- * null/undefined = no registry on disk = free-form keys, never reject.
45
- * Wrappers resolve it; the engine stays pure (no fs).
46
- */
47
- knownDomains?: string[] | null;
48
- }
49
-
50
- export interface EngineAddResult {
51
- id: string;
52
- kind: string;
53
- text: string;
54
- files: string[];
55
- spec?: string;
56
- key?: string;
57
- ts: string;
58
- }
59
-
60
- export function engineAdd(a: EngineAddArgs): EngineAddResult {
61
- if (!KINDS.includes(a.kind as WorkKind)) {
62
- throw new Error(`kind must be one of ${KINDS.join("|")} — got "${a.kind}"`);
63
- }
64
- if (a.files.length === 0) {
65
- throw new Error("files must contain at least one path");
66
- }
67
- if (!a.text.trim()) {
68
- throw new Error("text is required and must not be empty");
69
- }
70
- // Without a spec a hold can never be resolved by rotate — reject at write.
71
- if (a.kind === "hold" && !a.spec) {
72
- throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
73
- }
74
- // One validator for both surfaces (CLI argv reaches here too) — reject loudly
75
- // with a usable example, never silently drop the key (SPEC-mem-keys §Validation).
76
- if (a.key !== undefined && !KEY_RE.test(a.key)) {
77
- throw new Error(
78
- `key must match [a-z0-9-]{3,40}(:[a-z0-9-]{1,40})? — e.g. "fix-stop-dedupe" or "auth:login", got "${a.key}"`,
79
- );
80
- }
81
- // Registry gate (PLAN-mem-core chunk 4): the domain half must be declared.
82
- const domainError =
83
- a.key !== undefined ? checkKeyDomain(a.key, a.knownDomains) : null;
84
- if (domainError) throw new Error(domainError);
85
-
86
- const all = rows();
87
- if (a.kind === "next" && !process.env.MEM_FORCE) {
88
- const openNext = openRows(all).filter((r) => r.kind === "next").length;
89
- if (openNext >= CAP_NEXT) {
90
- throw new CapError(
91
- "next",
92
- `open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
93
- );
94
- }
95
- }
96
- if (a.kind === "hold" && !process.env.MEM_FORCE) {
97
- const openHold = openRows(all).filter((r) => r.kind === "hold").length;
98
- if (openHold >= CAP_HOLD) {
99
- throw new CapError(
100
- "hold",
101
- `open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
102
- );
103
- }
104
- }
105
-
106
- const id = nextId(all);
107
- const ts = new Date().toISOString();
108
- // Every row written from here on is v:2 — key optional, but the version
109
- // stamps the schema so a reader can tell new rows from v:1 legacy ones.
110
- // JSON.stringify drops the undefined key, so keyless rows carry only v.
111
- put({
112
- id,
113
- kind: a.kind as WorkKind,
114
- text: a.text,
115
- spec: a.spec,
116
- files: a.files,
117
- key: a.key,
118
- v: 2,
119
- });
120
- return {
121
- id,
122
- kind: a.kind,
123
- text: a.text,
124
- files: a.files,
125
- spec: a.spec,
126
- key: a.key,
127
- ts,
128
- };
129
- }
130
-
131
- export interface EngineCloseArgs {
132
- id: string;
133
- text: string;
134
- }
135
-
136
- export interface EngineCloseResult {
137
- ref: string;
138
- text: string;
139
- ts: string;
140
- }
141
-
142
- export function engineClose(a: EngineCloseArgs): EngineCloseResult {
143
- if (!a.id.trim()) {
144
- throw new Error("id is required");
145
- }
146
- if (!a.text.trim()) {
147
- throw new Error("text is required and must not be empty");
148
- }
149
-
150
- const all = rows();
151
- if (!all.some((r) => "id" in r && r.id === a.id)) {
152
- throw new Error(`no id "${a.id}" in the log`);
153
- }
154
-
155
- const ts = new Date().toISOString();
156
- put({ kind: "close", ref: a.id, text: a.text });
157
-
158
- return { ref: a.id, text: a.text, ts };
159
- }
160
-
161
- // --- find ---
162
- //
163
- // The single query engine for mem find. Both entry points call it with rows
164
- // they already read — CLI (commands/read.ts cmdFind, via allRows() which
165
- // includes rotated archives) and MCP (adapters/mcp/tools/mem.ts memFind, via
166
- // readMemLog whose loose log*.jsonl regex includes log.YYYY-MM-DD.jsonl too).
167
- // Archive scope is therefore identical on both sides with no parameter for it
168
- // (PLAN-unify-mem-engine chunk 2 §4.1: the "MCP reads live only" premise was
169
- // wrong — both see archives, base rate 0 archive files vs 211 live rows).
170
- //
171
- // The engine is pure: it takes rows + typed params, never touches argv
172
- // strings or the store. Wrappers own their surface — CLI parses
173
- // --kind/--files/--since/--limit/--open at the argv layer, MCP validates its JSON
174
- // shape — and each passes its own kind default (PLAN §5 escape):
175
- // MCP passes no exclude (contract: "every kind, no default filter", locked by
176
- // test), CLI passes the bookkeeping exclude to keep its legacy output.
177
-
178
- export const FIND_DEFAULT_LIMIT = 20;
179
-
180
- /** Bookkeeping rows the CLI hides unless explicitly asked via --kind. */
181
- export const CLI_FIND_EXCLUDE = ["close", "synced", "claim", "release"];
182
-
183
- export interface EngineFindArgs {
184
- /** Substring over text/spec/ref, case-insensitive. Omit = no text filter. */
185
- text?: string;
186
- /** Repo-relative paths — stored files[] first, text/spec/ref fallback. */
187
- files?: string[];
188
- /** Include filter — when non-empty, wins over excludeKind. */
189
- kind?: string[];
190
- /** Exclude filter — applied only when kind is empty. */
191
- excludeKind?: string[];
192
- /** ISO timestamp — only rows at or after this time (inclusive). */
193
- sinceIso?: string;
194
- /** Max rows returned (total still counts all matches). Default 20. */
195
- limit?: number;
196
- /**
197
- * true = unresolved work only: drops bookkeeping kinds
198
- * (close/claim/release/synced) plus work rows a close row points at.
199
- * Mirrors selectors.openRows but stays generic (no store types) so MCP rows
200
- * qualify. Default false — recall shows closed rows too.
201
- */
202
- open?: boolean;
203
- /**
204
- * Problem-identity match — a bare domain doubles as a prefix (`auth`
205
- * catches every `auth:*`; `auth:login` is exact). Rows whose effective key
206
- * differs fall out (v:1 rows included; they stay reachable via
207
- * files/text). A close row matches through the key of the work row its
208
- * ref points at (derived at read time, never stored). A pure miss also
209
- * returns knownKeys.
210
- */
211
- key?: string;
212
- }
213
-
214
- export interface EngineFindResult<T> {
215
- rows: T[];
216
- total: number;
217
- /** Present only on a pure key miss — every distinct key in `all`, sorted. */
218
- knownKeys?: string[];
219
- }
220
-
221
- export type FindableRow = {
222
- kind: string;
223
- text?: string;
224
- spec?: string;
225
- ref?: string;
226
- files?: string[];
227
- key?: string;
228
- ts: string;
229
- };
230
-
231
- export function engineFind<T extends FindableRow>(
232
- all: T[],
233
- a: EngineFindArgs,
234
- ): EngineFindResult<T> {
235
- let out = [...all];
236
-
237
- if (a.open === true) {
238
- const dead = new Set(
239
- all
240
- .filter((r) => r.kind === "close" && typeof r.ref === "string")
241
- .map((r) => r.ref as string),
242
- );
243
- const BOOKKEEPING = new Set(["close", "claim", "release", "synced"]);
244
- out = out.filter(
245
- (r) =>
246
- !BOOKKEEPING.has(r.kind) &&
247
- !("id" in r && typeof r.id === "string" && dead.has(r.id)),
248
- );
249
- }
250
-
251
- if (a.sinceIso) {
252
- const since = a.sinceIso;
253
- out = out.filter((r) => !(r.ts < since));
254
- }
255
-
256
- if (a.kind && a.kind.length > 0) {
257
- const keep = new Set(a.kind);
258
- out = out.filter((r) => keep.has(r.kind));
259
- } else if (a.excludeKind && a.excludeKind.length > 0) {
260
- const drop = new Set(a.excludeKind);
261
- out = out.filter((r) => !drop.has(r.kind));
262
- }
263
-
264
- // Domain-prefix key match, before text/files — a wrong key must never fall
265
- // through to substring luck. Close rows derive their key from the ref'd
266
- // work row (read-time derive: match only; the row itself stays keyless).
267
- // knownKeys fires only on a pure key miss — answer a wrong guess with the
268
- // real list.
269
- let knownKeys: string[] | undefined;
270
- if (a.key) {
271
- const want = a.key;
272
- const idKey = new Map<string, string>();
273
- for (const r of all) {
274
- if ("id" in r && typeof r.id === "string" && r.key) {
275
- idKey.set(r.id, r.key);
276
- }
277
- }
278
- const eff = (r: T): string | undefined =>
279
- r.key ??
280
- (r.kind === "close" && typeof r.ref === "string"
281
- ? idKey.get(r.ref)
282
- : undefined);
283
- out = out.filter((r) => keyMatchesQuery(eff(r), want));
284
- if (!all.some((r) => keyMatchesQuery(eff(r), want))) {
285
- const distinct = new Set<string>();
286
- for (const r of all) {
287
- if (r.key) distinct.add(r.key);
288
- }
289
- knownKeys = [...distinct].sort();
290
- }
291
- }
292
-
293
- if (a.text?.trim()) {
294
- const needle = a.text.toLowerCase();
295
- out = out.filter((r) =>
296
- `${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`
297
- .toLowerCase()
298
- .includes(needle),
299
- );
300
- }
301
-
302
- if (a.files && a.files.length > 0) {
303
- // Rows written by `mem add --files` carry files[] — match that first.
304
- // Older rows (and any row whose author skipped --files) have none, so the
305
- // text/spec/ref substring stays as the fallback: low recall by nature,
306
- // a limit of the data rather than of the query.
307
- const paths = a.files.map((f) => f.toLowerCase());
308
- out = out.filter((r) => {
309
- const stored = (r.files ?? []).map((f) => f.toLowerCase());
310
- if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
311
- return true;
312
- const hay =
313
- `${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
314
- return paths.some((p) => hay.includes(p));
315
- });
316
- }
317
-
318
- // Newest first — the canonical order. The CLI wrapper reprints oldest-first
319
- // to keep its legacy output byte-identical (same set, legacy order).
320
- out.sort((x, y) => y.ts.localeCompare(x.ts));
321
-
322
- const total = out.length;
323
- const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
324
- return {
325
- rows: out.slice(0, limit),
326
- total,
327
- ...(knownKeys ? { knownKeys } : {}),
328
- };
329
- }
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, bare or domain:sub (e.g. auth:login) — the domain must be in .fapony/keys.json when that file exists (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 problem-identity match — a bare domain catches every domain:* (auth matches auth:login); 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
- }
@@ -1,116 +0,0 @@
1
- // src/mem/key-registry.ts — domain key registry (`domain:sub`).
2
- //
3
- // PLAN-mem-core chunk 4: keys an agent invents never converge (3%), so a repo
4
- // that wants convergence declares its domains in `.fapony/keys.json`, beside
5
- // `conventions.json` — fixed path, no config field (rule: never add a field
6
- // derivable from structure). No file = keys stay free-form (never reject);
7
- // a file = the domain half must be listed, the sub half stays free.
8
- //
9
- // Shape: {"domains": ["auth", "hook"]} (a bare ["auth"] array reads the same).
10
-
11
- import { existsSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
13
- import { FAPONY_DIR, KEYS_FILE, KEYS_FILENAME } from "../core/config.js";
14
- import { KEY_SEGMENT_RE } from "../core/mem-log.js";
15
- import { resolveMemDir } from "../memory.js";
16
-
17
- /** Split `domain:sub` — null domain = a bare legacy key, always allowed. */
18
- export function splitKey(key: string): { domain: string; sub: string } | null {
19
- const i = key.indexOf(":");
20
- if (i < 0) return null;
21
- return { domain: key.slice(0, i), sub: key.slice(i + 1) };
22
- }
23
-
24
- /**
25
- * A bare domain query doubles as a prefix: `auth` matches the exact key
26
- * `auth` and every `auth:*`. A query carrying a colon is an exact match —
27
- * `auth:login` never pulls in `auth:logout`.
28
- */
29
- export function keyMatchesQuery(
30
- rowKey: string | undefined,
31
- query: string,
32
- ): boolean {
33
- if (!rowKey) return false;
34
- if (query.includes(":")) return rowKey === query;
35
- return rowKey === query || rowKey.startsWith(`${query}:`);
36
- }
37
-
38
- export function resolveKeysPath(worktree: string): string | null {
39
- // Same anchor as conventions.json: the .fapony/ dir holding the mem log,
40
- // so the registry and the log cannot drift apart.
41
- const memDir = resolveMemDir(worktree);
42
- const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
43
- const app = join(base, KEYS_FILENAME);
44
- if (existsSync(app)) return app;
45
- const root = join(worktree, KEYS_FILE);
46
- return existsSync(root) ? root : null;
47
- }
48
-
49
- export interface KeyRegistry {
50
- path: string | null;
51
- /** Sorted, deduped, segment-valid — the list reject messages print. */
52
- domains: string[];
53
- warnings: string[];
54
- }
55
-
56
- /** Missing file = empty + no error (same contract as conventions.json). */
57
- export function loadKeyRegistry(worktree: string): KeyRegistry {
58
- const path = resolveKeysPath(worktree);
59
- if (!path) return { path: null, domains: [], warnings: [] };
60
- let raw: string;
61
- try {
62
- raw = readFileSync(path, "utf-8");
63
- } catch {
64
- return { path, domains: [], warnings: [`keys.json unreadable: ${path}`] };
65
- }
66
- let parsed: unknown;
67
- try {
68
- parsed = JSON.parse(raw);
69
- } catch (e) {
70
- return {
71
- path,
72
- domains: [],
73
- warnings: [
74
- `keys.json is not valid JSON — ${
75
- e instanceof Error ? e.message.split("\n")[0] : "parse error"
76
- }`,
77
- ],
78
- };
79
- }
80
- const rows: unknown[] = Array.isArray(parsed)
81
- ? parsed
82
- : Array.isArray((parsed as { domains?: unknown }).domains)
83
- ? (parsed as { domains: unknown[] }).domains
84
- : [];
85
- const seen = new Set<string>();
86
- let dropped = 0;
87
- for (const r of rows) {
88
- if (typeof r === "string" && KEY_SEGMENT_RE.test(r)) seen.add(r);
89
- else dropped++;
90
- }
91
- const warnings =
92
- dropped > 0
93
- ? [
94
- `keys.json: ${dropped} entr${dropped === 1 ? "y" : "ies"} dropped (not [a-z0-9-]{3,40})`,
95
- ]
96
- : [];
97
- return { path, domains: [...seen].sort(), warnings };
98
- }
99
-
100
- /**
101
- * null domains = no registry on disk = free-form keys (never reject).
102
- * A registry gates only the domain half; bare keys stay backward compatible.
103
- */
104
- export function checkKeyDomain(
105
- key: string,
106
- domains: string[] | null | undefined,
107
- ): string | null {
108
- if (domains == null) return null;
109
- const split = splitKey(key);
110
- if (!split) return null;
111
- if (domains.includes(split.domain)) return null;
112
- return (
113
- `unknown key domain "${split.domain}" — known domains: ` +
114
- (domains.length ? domains.join(", ") : "(none yet)")
115
- );
116
- }