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.
- package/README.md +111 -46
- package/fapony.ts +35 -2
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +175 -5
- package/src/conventions-seed.ts +3 -3
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +11 -9
- package/src/db/load.ts +2 -2
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +2 -4
- package/src/debt.ts +176 -36
- package/src/digest/collect.ts +19 -2
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +665 -24
- package/src/init-mem.ts +123 -37
- package/src/init.ts +57 -39
- package/src/install/claude.ts +44 -8
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +169 -5
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +3 -8
- package/src/mcp/evidence.ts +15 -3
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +350 -0
- package/src/memory.ts +238 -40
- package/src/plan-seed.ts +26 -6
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +7 -8
- package/src/stats/data.ts +40 -104
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /package/{templates → src}/mem/selectors.ts +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// commands/read.ts — read-only commands: now (default), done, stale, find, kickoff
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
|
|
6
|
+
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
7
|
+
import type { CloseRow, WorkRow } from "../store.js";
|
|
8
|
+
import { allRows, app, memCmd, planDir, rows } from "../store.js";
|
|
9
|
+
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
10
|
+
import { THRESHOLD } from "./rotate.js";
|
|
11
|
+
|
|
12
|
+
const rotateLine = (n: number) =>
|
|
13
|
+
n >= THRESHOLD
|
|
14
|
+
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
15
|
+
: "";
|
|
16
|
+
|
|
17
|
+
const planSweepLine = () => {
|
|
18
|
+
const pending = shippedNotMoved();
|
|
19
|
+
return pending.length
|
|
20
|
+
? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
|
|
21
|
+
pending.map((n) => `- ${n}`).join("\n") +
|
|
22
|
+
`\n(move: ${planSweepCmd} <file.md> --apply)`
|
|
23
|
+
: "";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const cmdNow = () => {
|
|
27
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
28
|
+
const all = rows();
|
|
29
|
+
console.log(`# ${app} — ${all.length} entries`);
|
|
30
|
+
printOpenRows(all, { showHold: true });
|
|
31
|
+
const decisionN = openRows(all).filter((r) => r.kind === "decision").length;
|
|
32
|
+
const noteN = openRows(all).filter((r) => r.kind === "note").length;
|
|
33
|
+
console.log(
|
|
34
|
+
`\n## decision ${decisionN} · note ${noteN} — search: ${memCmd} find <word>`,
|
|
35
|
+
);
|
|
36
|
+
const stale = staleReport(all);
|
|
37
|
+
if (stale.length)
|
|
38
|
+
console.log(
|
|
39
|
+
`\n## ⚠ stale (${stale.length})\n` +
|
|
40
|
+
stale.map((l) => `- ${l}`).join("\n"),
|
|
41
|
+
);
|
|
42
|
+
const sweep = planSweepLine();
|
|
43
|
+
if (sweep) console.log(sweep);
|
|
44
|
+
const rotate = rotateLine(all.length);
|
|
45
|
+
if (rotate) console.log(rotate);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const cmdDone = () => {
|
|
49
|
+
const all = rows();
|
|
50
|
+
for (const l of doneLines(all)) console.log(l);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const cmdStale = () => {
|
|
54
|
+
for (const l of staleReport(rows())) console.log(l);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const cmdFind = (a: string[]) => {
|
|
58
|
+
// mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
|
|
59
|
+
const q = a.join(" ").toLowerCase();
|
|
60
|
+
if (!q) {
|
|
61
|
+
console.error(`usage: ${memCmd} find <word>`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// allRows, not rows: find is recall — rotated history counts
|
|
65
|
+
const hits = allRows()
|
|
66
|
+
.filter(
|
|
67
|
+
(r): r is WorkRow =>
|
|
68
|
+
r.kind !== "close" &&
|
|
69
|
+
r.kind !== "synced" &&
|
|
70
|
+
r.kind !== "claim" &&
|
|
71
|
+
r.kind !== "release",
|
|
72
|
+
)
|
|
73
|
+
.filter(
|
|
74
|
+
(r) =>
|
|
75
|
+
r.text.toLowerCase().includes(q) ||
|
|
76
|
+
(r.spec ?? "").toLowerCase().includes(q),
|
|
77
|
+
)
|
|
78
|
+
.slice(-20);
|
|
79
|
+
for (const r of hits)
|
|
80
|
+
console.log(
|
|
81
|
+
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
82
|
+
);
|
|
83
|
+
if (!hits.length) console.log("(no matches)");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Read a plan file and extract unchecked checkboxes from the TL;DR section. */
|
|
87
|
+
const readPlanCheckboxes = (planPath: string): string[] => {
|
|
88
|
+
try {
|
|
89
|
+
const text = readFileSync(planPath, "utf8");
|
|
90
|
+
// Skip frontmatter
|
|
91
|
+
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
92
|
+
// Find the first ## section (TL;DR)
|
|
93
|
+
const start = body.search(/^##\s+/m);
|
|
94
|
+
if (start < 0) return [];
|
|
95
|
+
const rest = body.slice(start);
|
|
96
|
+
const next = rest.slice(3).search(/^##\s+/m);
|
|
97
|
+
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
98
|
+
// Extract unchecked checkboxes
|
|
99
|
+
const items: string[] = [];
|
|
100
|
+
for (const line of block.split("\n")) {
|
|
101
|
+
const m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
|
|
102
|
+
if (m) items.push(m[1].trim());
|
|
103
|
+
}
|
|
104
|
+
return items;
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Check if a plan has `priority: high` in its frontmatter. */
|
|
111
|
+
const hasHighPriority = (planPath: string): boolean => {
|
|
112
|
+
try {
|
|
113
|
+
const head = readFileSync(planPath, "utf8").slice(0, 1024);
|
|
114
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
|
|
115
|
+
if (!m) return false;
|
|
116
|
+
return /^\s*priority\s*:\s*high\s*$/m.test(m[1]);
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Read the title from a plan file (first # heading). */
|
|
123
|
+
const readPlanTitle = (planPath: string): string => {
|
|
124
|
+
try {
|
|
125
|
+
const text = readFileSync(planPath, "utf8");
|
|
126
|
+
const m = /^#\s+(.+)$/m.exec(text);
|
|
127
|
+
return m ? m[1].trim() : "";
|
|
128
|
+
} catch {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const normSpec = (s: string) => s.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
134
|
+
|
|
135
|
+
// kickoff's spec arg used to be compared to r.spec as a literal string, so
|
|
136
|
+
// `.fapony/plan/PLAN-x.md` had to be retyped byte-for-byte — and a typo printed
|
|
137
|
+
// "(no entries)" with nothing to notice. Resolve against the spec paths the log
|
|
138
|
+
// actually holds: exact path first, then by filename (with or without .md), then by
|
|
139
|
+
// any path suffix. Returns all candidates so the caller can tell unique from ambiguous.
|
|
140
|
+
const resolveSpec = (target: string, specs: string[]): string[] => {
|
|
141
|
+
const t = normSpec(target);
|
|
142
|
+
const all = [...new Set(specs)];
|
|
143
|
+
const exact = all.filter((s) => normSpec(s) === t);
|
|
144
|
+
if (exact.length) return exact;
|
|
145
|
+
const base = basename(t);
|
|
146
|
+
const withMd = base.endsWith(".md") ? base : `${base}.md`;
|
|
147
|
+
const byName = all.filter((s) => {
|
|
148
|
+
const b = basename(normSpec(s));
|
|
149
|
+
return b === base || b === withMd;
|
|
150
|
+
});
|
|
151
|
+
if (byName.length) return byName;
|
|
152
|
+
return all.filter((s) => normSpec(s).endsWith(`/${t}`));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const cmdKickoff = (a: string[]) => {
|
|
156
|
+
// mem kickoff [id|spec.md] [--pick <n>]
|
|
157
|
+
// the spec may be the full path or just its filename
|
|
158
|
+
const all = rows();
|
|
159
|
+
|
|
160
|
+
// Parse --pick flag first
|
|
161
|
+
let pickIdx: number | null = null;
|
|
162
|
+
const pickFlagIdx = a.indexOf("--pick");
|
|
163
|
+
if (pickFlagIdx !== -1) {
|
|
164
|
+
const val = a[pickFlagIdx + 1];
|
|
165
|
+
pickIdx = parseInt(val ?? "", 10);
|
|
166
|
+
if (Number.isNaN(pickIdx) || pickIdx < 1) {
|
|
167
|
+
console.error("--pick requires a positive number");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Filter out --pick and its value to get the main arguments
|
|
172
|
+
const mainArgs =
|
|
173
|
+
pickFlagIdx !== -1
|
|
174
|
+
? a.filter((_, i) => i !== pickFlagIdx && i !== pickFlagIdx + 1)
|
|
175
|
+
: a;
|
|
176
|
+
const arg = mainArgs[0] ?? "";
|
|
177
|
+
|
|
178
|
+
// --- resolve spec/id for the main output ---
|
|
179
|
+
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
180
|
+
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
181
|
+
const specNames = [
|
|
182
|
+
...new Set(
|
|
183
|
+
all.flatMap((r) => ("spec" in r && r.spec ? [r.spec as string] : [])),
|
|
184
|
+
),
|
|
185
|
+
].sort();
|
|
186
|
+
const target = byId.get(arg);
|
|
187
|
+
const matches = resolveSpec(arg, specNames);
|
|
188
|
+
let resolvedSpec = matches.find((s) => normSpec(s) === normSpec(arg));
|
|
189
|
+
|
|
190
|
+
// If no arg or arg didn't match an id/spec, check if it's a plan file
|
|
191
|
+
let planFile: string | null = null;
|
|
192
|
+
let planCheckboxes: string[] = [];
|
|
193
|
+
|
|
194
|
+
if (arg && !resolvedSpec && !target) {
|
|
195
|
+
if (matches.length === 1) {
|
|
196
|
+
resolvedSpec = matches[0];
|
|
197
|
+
} else if (matches.length > 1) {
|
|
198
|
+
console.error(
|
|
199
|
+
`"${arg}" could mean ${matches.length} specs — retype one in full:`,
|
|
200
|
+
);
|
|
201
|
+
for (const m of matches) console.error(` ${m}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
} else if (arg.endsWith(".md")) {
|
|
204
|
+
// Try to find it as a plan file
|
|
205
|
+
const dir = planDir;
|
|
206
|
+
const candidates = [join(dir, basename(arg)), join(dir, arg), arg];
|
|
207
|
+
for (const c of candidates) {
|
|
208
|
+
if (existsSync(c)) {
|
|
209
|
+
planFile = c;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!planFile) {
|
|
214
|
+
console.error(`no entries for spec "${arg}"`);
|
|
215
|
+
if (specNames.length) {
|
|
216
|
+
console.error(`specs in the log:`);
|
|
217
|
+
for (const s of specNames) console.error(` ${s}`);
|
|
218
|
+
}
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Read plan file if we found one (or if a .md arg matched a spec)
|
|
225
|
+
if (planFile) {
|
|
226
|
+
planCheckboxes = readPlanCheckboxes(planFile);
|
|
227
|
+
} else if (arg && resolvedSpec && arg.endsWith(".md")) {
|
|
228
|
+
// The spec resolved from the log might be a plan file — try to read it
|
|
229
|
+
const dir = planDir;
|
|
230
|
+
const byName = join(dir, basename(arg));
|
|
231
|
+
if (existsSync(byName)) {
|
|
232
|
+
planFile = byName;
|
|
233
|
+
planCheckboxes = readPlanCheckboxes(planFile);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- main output (unchanged behavior) ---
|
|
238
|
+
|
|
239
|
+
if (!arg && !planFile) {
|
|
240
|
+
// no args = now + a "recent" section = the last 10 closes
|
|
241
|
+
console.log(`# ${app} — ${all.length} entries`);
|
|
242
|
+
printOpenRows(all, { showHold: true });
|
|
243
|
+
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
244
|
+
const stale = staleReport(all);
|
|
245
|
+
if (stale.length)
|
|
246
|
+
console.log(
|
|
247
|
+
`\n## ⚠ stale (${stale.length})\n` +
|
|
248
|
+
stale.map((l) => `- ${l}`).join("\n"),
|
|
249
|
+
);
|
|
250
|
+
const sweep = planSweepLine();
|
|
251
|
+
if (sweep) console.log(sweep);
|
|
252
|
+
const rotate = rotateLine(all.length);
|
|
253
|
+
if (rotate) console.log(rotate);
|
|
254
|
+
} else if (resolvedSpec) {
|
|
255
|
+
// spec.md = a brief for that spec
|
|
256
|
+
const open = openRows(all);
|
|
257
|
+
const specRows = open.filter((r) => r.spec === resolvedSpec);
|
|
258
|
+
const specDecisions = all
|
|
259
|
+
.filter(
|
|
260
|
+
(r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
|
|
261
|
+
)
|
|
262
|
+
.slice(-5);
|
|
263
|
+
const specCloses = all
|
|
264
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
265
|
+
.filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
|
|
266
|
+
.slice(-3);
|
|
267
|
+
|
|
268
|
+
console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
|
|
269
|
+
if (specRows.length) {
|
|
270
|
+
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
271
|
+
}
|
|
272
|
+
if (specDecisions.length) {
|
|
273
|
+
console.log(
|
|
274
|
+
`\n## decisions\n` +
|
|
275
|
+
specDecisions
|
|
276
|
+
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
277
|
+
.join("\n"),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (specCloses.length) {
|
|
281
|
+
console.log(
|
|
282
|
+
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
286
|
+
console.log("(no entries for this spec)");
|
|
287
|
+
}
|
|
288
|
+
} else if (target) {
|
|
289
|
+
// id = a brief for that task
|
|
290
|
+
const claims = claimsOf(all);
|
|
291
|
+
const claimInfo = claims.has(arg)
|
|
292
|
+
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
293
|
+
: "";
|
|
294
|
+
|
|
295
|
+
const spec = target.spec;
|
|
296
|
+
const open = spec
|
|
297
|
+
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
298
|
+
: [];
|
|
299
|
+
const specDecisions = spec
|
|
300
|
+
? all
|
|
301
|
+
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
302
|
+
.slice(-5)
|
|
303
|
+
: [];
|
|
304
|
+
const specNotes = spec
|
|
305
|
+
? all
|
|
306
|
+
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
307
|
+
.slice(-5)
|
|
308
|
+
: [];
|
|
309
|
+
const specCloses = spec
|
|
310
|
+
? all
|
|
311
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
312
|
+
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
313
|
+
.slice(-3)
|
|
314
|
+
: [];
|
|
315
|
+
|
|
316
|
+
console.log(
|
|
317
|
+
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
318
|
+
);
|
|
319
|
+
if (open.length) {
|
|
320
|
+
console.log(
|
|
321
|
+
`\n## open (same spec)\n` +
|
|
322
|
+
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (specDecisions.length || specNotes.length) {
|
|
326
|
+
console.log(`\n## decisions & notes (spec)\n`);
|
|
327
|
+
for (const r of [...specDecisions, ...specNotes]) {
|
|
328
|
+
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (specCloses.length) {
|
|
332
|
+
console.log(
|
|
333
|
+
`\n## closes (spec)\n` +
|
|
334
|
+
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
} else if (arg) {
|
|
338
|
+
console.error(`no id "${arg}" in the log`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- next up section (chunk 3) ---
|
|
343
|
+
|
|
344
|
+
// A suggestion may carry a fully-runnable command. `manual` marks a command
|
|
345
|
+
// that is a template with a placeholder (bug close) — show it, never auto-run it.
|
|
346
|
+
type Suggestion = { text: string; run?: string; manual?: boolean };
|
|
347
|
+
const suggestions: Suggestion[] = [];
|
|
348
|
+
const GROUP_CAP = 3; // SPEC §3: at most 3 groups, at most 3 lines each
|
|
349
|
+
|
|
350
|
+
const truncate = (s: string, n = 80) =>
|
|
351
|
+
s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
|
352
|
+
|
|
353
|
+
// group 1 — plans: priority: high first, then the first unchecked chunk
|
|
354
|
+
const planLines: Suggestion[] = [];
|
|
355
|
+
if (existsSync(planDir)) {
|
|
356
|
+
const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
|
|
357
|
+
for (const file of files) {
|
|
358
|
+
if (planLines.length >= GROUP_CAP) break;
|
|
359
|
+
const fullPath = join(planDir, file);
|
|
360
|
+
// Skip the plan we already showed
|
|
361
|
+
if (planFile && fullPath === planFile) continue;
|
|
362
|
+
if (hasHighPriority(fullPath)) {
|
|
363
|
+
const title = readPlanTitle(fullPath);
|
|
364
|
+
planLines.push({
|
|
365
|
+
text: `${title || file} (${file})`,
|
|
366
|
+
run: `fapony mem kickoff ${file}`,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Only the first unchecked chunk (SPEC §3) — the body of the plan is the plan's job.
|
|
372
|
+
// There is no per-chunk command, so this line is context, not a runnable item.
|
|
373
|
+
if (planLines.length < GROUP_CAP && planCheckboxes.length) {
|
|
374
|
+
const planName = planFile ? basename(planFile) : "plan";
|
|
375
|
+
planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
|
|
376
|
+
}
|
|
377
|
+
suggestions.push(...planLines);
|
|
378
|
+
|
|
379
|
+
// group 2 — open bugs, oldest first. openRows excludes tombstoned refs; the
|
|
380
|
+
// old bug filter forgot that and offered already-closed bugs as work.
|
|
381
|
+
const claims = claimsOf(all);
|
|
382
|
+
const openBugs = openRows(all)
|
|
383
|
+
.filter((r) => r.kind === "bug" && !claims.has(r.id))
|
|
384
|
+
.slice(0, GROUP_CAP);
|
|
385
|
+
for (const b of openBugs) {
|
|
386
|
+
suggestions.push({
|
|
387
|
+
text: `bug #${b.id} — ${truncate(b.text)}`,
|
|
388
|
+
run: `fapony mem close ${b.id} "<msg>"`,
|
|
389
|
+
manual: true,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// group 3 — the files of the last 3 rows that carried files[]
|
|
394
|
+
const recentFiles = all
|
|
395
|
+
.filter(
|
|
396
|
+
(r): r is WorkRow =>
|
|
397
|
+
"id" in r && "files" in r && !!(r as WorkRow).files?.length,
|
|
398
|
+
)
|
|
399
|
+
.slice(-3)
|
|
400
|
+
.flatMap((r) => r.files ?? []);
|
|
401
|
+
const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
|
|
402
|
+
if (uniqueRecent.length) {
|
|
403
|
+
suggestions.push({
|
|
404
|
+
text: `แตะล่าสุด: ${uniqueRecent.join(", ")}`,
|
|
405
|
+
run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Print and optionally execute. Only a suggestion with a complete command is
|
|
410
|
+
// runnable — a plan item is context, a bug's close is a template needing a message.
|
|
411
|
+
if (suggestions.length) {
|
|
412
|
+
console.log(`\n## next up`);
|
|
413
|
+
for (let i = 0; i < suggestions.length; i++) {
|
|
414
|
+
console.log(` [${i + 1}] ${suggestions[i].text}`);
|
|
415
|
+
if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (pickIdx !== null) {
|
|
419
|
+
if (pickIdx > suggestions.length) {
|
|
420
|
+
console.error(
|
|
421
|
+
`--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
|
|
422
|
+
);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const picked = suggestions[pickIdx - 1];
|
|
426
|
+
if (!picked.run) {
|
|
427
|
+
console.error(
|
|
428
|
+
`--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
|
|
429
|
+
);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
if (picked.manual) {
|
|
433
|
+
console.error(
|
|
434
|
+
`--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
|
|
435
|
+
);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const cmd = picked.run;
|
|
439
|
+
console.log(`\n> running: ${cmd}`);
|
|
440
|
+
const { execSync } = require("node:child_process");
|
|
441
|
+
try {
|
|
442
|
+
execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
|
|
443
|
+
} catch {
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/mem/commands/where.ts — `fapony mem where`: show which mem dir is resolved and how
|
|
2
|
+
|
|
3
|
+
import { whereMemDir } from "../../memory.js";
|
|
4
|
+
|
|
5
|
+
const USAGE = "usage: fapony mem [--mem-dir <path>] where [--from <dir>]";
|
|
6
|
+
|
|
7
|
+
export function cmdWhere(args: string[], memDir?: string): void {
|
|
8
|
+
let fromDir: string | undefined;
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
if (args[i] === "--from") {
|
|
12
|
+
const v = args[i + 1];
|
|
13
|
+
if (!v || v.startsWith("--")) {
|
|
14
|
+
console.error(`fapony mem where: --from needs a value\n${USAGE}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
fromDir = v;
|
|
18
|
+
i++;
|
|
19
|
+
} else if (args[i] === "-h" || args[i] === "--help") {
|
|
20
|
+
console.log(USAGE);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = whereMemDir(fromDir, memDir);
|
|
26
|
+
|
|
27
|
+
if (result.step === "ambiguous") {
|
|
28
|
+
console.error(
|
|
29
|
+
"fapony mem: two mem dirs at the same level, refusing to guess",
|
|
30
|
+
);
|
|
31
|
+
for (const c of result.candidates ?? []) console.error(` ${c}`);
|
|
32
|
+
console.error("cd into one of them, set paths.memDir, or pass --mem-dir");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!result.dir) {
|
|
37
|
+
console.log(
|
|
38
|
+
`fapony mem where — no .fapony/.memory/ found (step: ${result.step})`,
|
|
39
|
+
);
|
|
40
|
+
console.log(" Create one with: fapony init <path>");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const labels: Record<string, string> = {
|
|
45
|
+
flag: "--mem-dir flag",
|
|
46
|
+
config: "paths.memDir in fapony.config.json",
|
|
47
|
+
"walk-up": "walked up from cwd",
|
|
48
|
+
"repo-root": "git repo root fallback",
|
|
49
|
+
ambiguous: "ambiguous",
|
|
50
|
+
none: "not found",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
console.log(`fapony mem where`);
|
|
54
|
+
console.log(` dir: ${result.dir}`);
|
|
55
|
+
console.log(` step: ${labels[result.step] ?? result.step}`);
|
|
56
|
+
}
|
|
@@ -205,10 +205,17 @@ export const cmdHook = async () => {
|
|
|
205
205
|
? ((j as { tool_input?: { file_path?: string } }).tool_input?.file_path ??
|
|
206
206
|
"")
|
|
207
207
|
: "";
|
|
208
|
-
const {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
const { planDir, doneDir } = await import("../store.js");
|
|
209
|
+
const relF = f.startsWith(root) ? f.slice(root.length + 1) : f;
|
|
210
|
+
// match PLAN*.md under this project's plan/ or done/
|
|
211
|
+
const planRe = new RegExp(
|
|
212
|
+
`^${planDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
|
|
213
|
+
);
|
|
214
|
+
const doneRe = new RegExp(
|
|
215
|
+
`^${doneDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
|
|
216
|
+
);
|
|
217
|
+
if (planRe.test(relF) || doneRe.test(relF)) {
|
|
218
|
+
put({ kind: "synced", spec: relF });
|
|
219
|
+
console.log(`synced ${relF}`);
|
|
213
220
|
}
|
|
214
221
|
};
|
package/src/mem/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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 { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
|
|
7
|
+
import {
|
|
8
|
+
cmdDone,
|
|
9
|
+
cmdFind,
|
|
10
|
+
cmdKickoff,
|
|
11
|
+
cmdNow,
|
|
12
|
+
cmdStale,
|
|
13
|
+
} from "./commands/read.js";
|
|
14
|
+
import { cmdRotate } from "./commands/rotate.js";
|
|
15
|
+
import { cmdWhere } from "./commands/where.js";
|
|
16
|
+
import {
|
|
17
|
+
cmdAdd,
|
|
18
|
+
cmdClaim,
|
|
19
|
+
cmdClose,
|
|
20
|
+
cmdHook,
|
|
21
|
+
cmdRelease,
|
|
22
|
+
cmdSynced,
|
|
23
|
+
} from "./commands/write.js";
|
|
24
|
+
|
|
25
|
+
const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
|
|
26
|
+
|
|
27
|
+
subcommands:
|
|
28
|
+
where show the resolved mem dir and which step won
|
|
29
|
+
kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
|
|
30
|
+
add <kind> "<text>" --files f1,f2 [spec.md]
|
|
31
|
+
close <id> "<msg>" close a bug
|
|
32
|
+
find "<text>" substring-search every row
|
|
33
|
+
now | done | stale views
|
|
34
|
+
claim <id> | release <id> | synced bookkeeping
|
|
35
|
+
plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
|
|
36
|
+
plan-check <plan.md> validate a plan's frontmatter/sections
|
|
37
|
+
rotate archive the live log when it grows
|
|
38
|
+
hook Stop-hook payload reader (installed, not run by hand)
|
|
39
|
+
|
|
40
|
+
--mem-dir <path> wins over config and the walk-up (global to every sub)
|
|
41
|
+
example: fapony mem add note "decided X because Y" --files src/a.ts`;
|
|
42
|
+
|
|
43
|
+
const HELP: Record<string, string> = {
|
|
44
|
+
where: `usage: fapony mem where [--from <dir>]
|
|
45
|
+
show the .fapony/.memory dir the resolver picks and which step won — writes nothing
|
|
46
|
+
--from <dir> resolve as if cwd were here
|
|
47
|
+
example: fapony mem where`,
|
|
48
|
+
kickoff: `usage: fapony mem kickoff [<plan.md>] [--pick <n>]
|
|
49
|
+
open a session and print a "next up" list (priority plans, unchecked chunks, open bugs)
|
|
50
|
+
<plan.md> plan whose first unchecked chunk is offered as a context line
|
|
51
|
+
--pick <n> run suggestion [n] non-interactively; refuses context/template items
|
|
52
|
+
example: fapony mem kickoff .fapony/plan/PLAN-x.md`,
|
|
53
|
+
add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [spec.md]
|
|
54
|
+
append one row to the mem log
|
|
55
|
+
--files f1,f2 repo-relative paths this row is about (required)
|
|
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`,
|
|
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>"
|
|
63
|
+
substring search over every row's text/spec/ref — all kinds, no default filter
|
|
64
|
+
example: fapony mem find "usage-web"`,
|
|
65
|
+
now: `usage: fapony mem now
|
|
66
|
+
next + bug + hold still open (decision/note are search-only — use find)
|
|
67
|
+
example: fapony mem now`,
|
|
68
|
+
done: `usage: fapony mem done
|
|
69
|
+
closed rows with their tombstone message
|
|
70
|
+
example: fapony mem done`,
|
|
71
|
+
stale: `usage: fapony mem stale
|
|
72
|
+
decisions that never made it into their spec
|
|
73
|
+
example: fapony mem stale`,
|
|
74
|
+
claim: `usage: fapony mem claim <id>
|
|
75
|
+
claim an open next|bug before working it
|
|
76
|
+
example: fapony mem claim mt14`,
|
|
77
|
+
release: `usage: fapony mem release <id> [--stdin]
|
|
78
|
+
give back a claimed row without closing it
|
|
79
|
+
example: fapony mem release mt14`,
|
|
80
|
+
synced: `usage: fapony mem synced [<spec.md>]
|
|
81
|
+
mark a spec as carried into the log
|
|
82
|
+
example: fapony mem synced .fapony/spec/SPEC-x.md`,
|
|
83
|
+
"plan-sweep": `usage: fapony mem plan-sweep [<plan.md>] [--apply]
|
|
84
|
+
find shipped plans (or move one) and fix the links that point at it
|
|
85
|
+
--apply perform the git mv + link fixes (default = dry run)
|
|
86
|
+
example: fapony mem plan-sweep .fapony/plan/PLAN-x.md --apply`,
|
|
87
|
+
"plan-check": `usage: fapony mem plan-check <plan.md>
|
|
88
|
+
validate a plan's frontmatter and required sections
|
|
89
|
+
example: fapony mem plan-check .fapony/plan/PLAN-x.md`,
|
|
90
|
+
rotate: `usage: fapony mem rotate
|
|
91
|
+
archive the live log to log.YYYY-MM-DD.jsonl once it crosses the threshold
|
|
92
|
+
example: fapony mem rotate`,
|
|
93
|
+
hook: `usage: fapony mem hook
|
|
94
|
+
reads the Stop-hook payload on stdin — installed by fapony, not run by hand
|
|
95
|
+
example: (installed hook only)`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
99
|
+
const [cmd, ...rest] = a;
|
|
100
|
+
|
|
101
|
+
// SPEC §2: every subcommand answers --help with signature + flags + one runnable
|
|
102
|
+
// example, because the reader is an agent that gets one call to get it right.
|
|
103
|
+
if (cmd === "-h" || cmd === "--help") {
|
|
104
|
+
console.log(MEM_HELP);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
108
|
+
console.log(HELP[cmd] ?? MEM_HELP);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (cmd === "add") {
|
|
113
|
+
await cmdAdd(rest);
|
|
114
|
+
} else if (cmd === "close") {
|
|
115
|
+
await cmdClose(rest);
|
|
116
|
+
} else if (cmd === "claim") {
|
|
117
|
+
cmdClaim(rest);
|
|
118
|
+
} else if (cmd === "release") {
|
|
119
|
+
await cmdRelease(rest);
|
|
120
|
+
} else if (cmd === "synced") {
|
|
121
|
+
cmdSynced(rest);
|
|
122
|
+
} else if (cmd === "hook") {
|
|
123
|
+
await cmdHook();
|
|
124
|
+
} else if (cmd === "done") {
|
|
125
|
+
cmdDone();
|
|
126
|
+
} else if (cmd === "stale") {
|
|
127
|
+
cmdStale();
|
|
128
|
+
} else if (cmd === "find") {
|
|
129
|
+
cmdFind(rest);
|
|
130
|
+
} else if (cmd === "kickoff") {
|
|
131
|
+
cmdKickoff(rest);
|
|
132
|
+
} else if (cmd === "where") {
|
|
133
|
+
cmdWhere(rest, memDir);
|
|
134
|
+
} else if (cmd === "plan-sweep") {
|
|
135
|
+
cmdPlanSweep(rest);
|
|
136
|
+
} else if (cmd === "plan-check") {
|
|
137
|
+
cmdPlanCheck(rest);
|
|
138
|
+
} else if (cmd === "rotate") {
|
|
139
|
+
cmdRotate(rest);
|
|
140
|
+
} else {
|
|
141
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
142
|
+
cmdNow();
|
|
143
|
+
}
|
|
144
|
+
}
|