fapony 0.2.1 → 0.3.3
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 +95 -72
- package/fapony.ts +12 -5
- package/package.json +5 -4
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +38 -61
- package/src/analyze.ts +1 -1
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/gate.ts +3 -3
- package/src/hook.ts +349 -100
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +16 -123
- package/src/install/codex.ts +58 -19
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +131 -6
- package/src/install.ts +12 -3
- package/src/lint-baseline.ts +2 -2
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +13 -102
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +8 -94
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +231 -141
- package/src/mem/index.ts +4 -13
- package/src/memory.ts +17 -8
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/debt.ts +0 -811
- package/src/math.ts +0 -13
- package/src/mcp/tools/usage.ts +0 -211
- package/src/mcp/tools/verdict.ts +0 -161
package/src/mcp/transport.ts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
// src/mcp/transport.ts — JSON-RPC dispatch + stdio entry point
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
3
|
import { createInterface } from "node:readline";
|
|
7
|
-
import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
|
|
8
4
|
import { getServerSha } from "./primitives.js";
|
|
9
|
-
import {
|
|
10
|
-
TOOLS,
|
|
11
|
-
toolMemAdd,
|
|
12
|
-
toolMemFind,
|
|
13
|
-
toolPassiveUsage,
|
|
14
|
-
toolVerdictSubmit,
|
|
15
|
-
} from "./tools/index.js";
|
|
5
|
+
import { TOOLS, toolMemAdd, toolMemClose, toolMemFind } from "./tools/index.js";
|
|
16
6
|
import { errorResult, type ToolResult } from "./types.js";
|
|
17
7
|
|
|
18
8
|
// --- Server instructions ---
|
|
@@ -27,90 +17,19 @@ import { errorResult, type ToolResult } from "./types.js";
|
|
|
27
17
|
// Both habits degrade silently — an agent that ignores them still gets
|
|
28
18
|
// correct answers from every tool, just a thinner history.
|
|
29
19
|
|
|
30
|
-
const SERVER_INSTRUCTIONS = `fapony
|
|
20
|
+
const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
|
|
31
21
|
|
|
32
|
-
When a unit of work
|
|
22
|
+
When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
|
|
33
23
|
|
|
34
|
-
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the
|
|
24
|
+
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
|
|
35
25
|
|
|
36
26
|
Skip it and every tool still answers correctly, on a thinner history.`;
|
|
37
27
|
|
|
38
|
-
// --- Statusline cache ---
|
|
39
|
-
//
|
|
40
|
-
// Written after every MCP tool call. The Claude Code statusline script reads
|
|
41
|
-
// this file (< 1ms, no spawn, no db). Format: single line of text.
|
|
42
|
-
// Only fapony_usage with detail:true produces meaningful data (bytes_by_tool,
|
|
43
|
-
// aggregated across all clients — the bytes live on the claude_code
|
|
44
|
-
// sub-object, never top-level); other tools write a minimal "fapony" marker.
|
|
45
|
-
|
|
46
|
-
const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
|
|
47
|
-
|
|
48
|
-
function writeStatuslineCache(toolResult: ToolResult): void {
|
|
49
|
-
try {
|
|
50
|
-
// Extract bytes_by_tool from fapony_usage detail JSON response.
|
|
51
|
-
let line = "fapony";
|
|
52
|
-
if (
|
|
53
|
-
toolResult &&
|
|
54
|
-
typeof toolResult === "object" &&
|
|
55
|
-
"content" in toolResult &&
|
|
56
|
-
Array.isArray(toolResult.content)
|
|
57
|
-
) {
|
|
58
|
-
for (const c of toolResult.content) {
|
|
59
|
-
if (
|
|
60
|
-
c &&
|
|
61
|
-
typeof c === "object" &&
|
|
62
|
-
c.type === "text" &&
|
|
63
|
-
typeof c.text === "string"
|
|
64
|
-
) {
|
|
65
|
-
// Try to extract bytes_by_tool from JSON text response.
|
|
66
|
-
// Aggregated across all clients: the bytes live on the Claude Code
|
|
67
|
-
// sub-object (claude_code.detail), never on the top-level detail,
|
|
68
|
-
// so reading top-level alone would always miss.
|
|
69
|
-
try {
|
|
70
|
-
const parsed = JSON.parse(c.text) as {
|
|
71
|
-
detail?: UsageDetail | null;
|
|
72
|
-
zcode?: { detail?: UsageDetail | null } | null;
|
|
73
|
-
claude_code?: { detail?: UsageDetail | null } | null;
|
|
74
|
-
codex?: { detail?: UsageDetail | null } | null;
|
|
75
|
-
};
|
|
76
|
-
const bbt = mergeBytesByTool(
|
|
77
|
-
parsed?.detail,
|
|
78
|
-
parsed?.zcode?.detail,
|
|
79
|
-
parsed?.claude_code?.detail,
|
|
80
|
-
parsed?.codex?.detail,
|
|
81
|
-
);
|
|
82
|
-
const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
|
|
83
|
-
const total = entries.reduce((s, e) => s + e[1], 0);
|
|
84
|
-
if (total > 0) {
|
|
85
|
-
// Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
|
|
86
|
-
const fmt = (n: number) =>
|
|
87
|
-
n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
|
|
88
|
-
if (entries.length <= 3) {
|
|
89
|
-
line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
|
|
90
|
-
} else {
|
|
91
|
-
line = `fapony ${fmt(total)}`;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
} catch {
|
|
95
|
-
// Not JSON — that's fine, use default "fapony" marker.
|
|
96
|
-
}
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
const dir = join(homedir(), ".config", "fapony");
|
|
102
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
103
|
-
writeFileSync(STATUSLINE_PATH, line, "utf-8");
|
|
104
|
-
} catch {
|
|
105
|
-
// Cache write is best-effort — never block MCP on it.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
28
|
// --- MCP protocol constants ---
|
|
110
29
|
|
|
111
30
|
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
112
31
|
const SERVER_NAME = "fapony-handcheck";
|
|
113
|
-
const SERVER_VERSION = "0.
|
|
32
|
+
const SERVER_VERSION = "0.3.0";
|
|
114
33
|
|
|
115
34
|
// --- JSON-RPC dispatch ---
|
|
116
35
|
|
|
@@ -149,23 +68,18 @@ function dispatchToolCall(params: {
|
|
|
149
68
|
const args = params.arguments ?? {};
|
|
150
69
|
let result: ToolResult;
|
|
151
70
|
switch (params.name) {
|
|
152
|
-
case "verdict_submit":
|
|
153
|
-
result = toolVerdictSubmit(args);
|
|
154
|
-
break;
|
|
155
|
-
case "fapony_usage":
|
|
156
|
-
result = toolPassiveUsage(args);
|
|
157
|
-
break;
|
|
158
71
|
case "mem_find":
|
|
159
72
|
result = toolMemFind(args);
|
|
160
73
|
break;
|
|
161
74
|
case "mem_add":
|
|
162
75
|
result = toolMemAdd(args);
|
|
163
76
|
break;
|
|
77
|
+
case "mem_close":
|
|
78
|
+
result = toolMemClose(args);
|
|
79
|
+
break;
|
|
164
80
|
default:
|
|
165
81
|
return errorResult(`unknown tool: ${params.name}`);
|
|
166
82
|
}
|
|
167
|
-
// Write statusline cache after every tool call — best-effort, never blocks.
|
|
168
|
-
writeStatuslineCache(result);
|
|
169
83
|
return result;
|
|
170
84
|
}
|
|
171
85
|
|
package/src/mcp/worktree.ts
CHANGED
|
@@ -8,7 +8,7 @@ const SENTINEL_MCP_EXTERNAL = "mcp-external";
|
|
|
8
8
|
* Resolve a worktree argument to an absolute path.
|
|
9
9
|
* - Absolute paths (contain "/") pass through unchanged
|
|
10
10
|
* - Keys are looked up in config.worktrees
|
|
11
|
-
* - "mcp-external" sentinel passes through (
|
|
11
|
+
* - "mcp-external" sentinel passes through (used when no worktree is available)
|
|
12
12
|
* - Throws if key not found in config
|
|
13
13
|
*/
|
|
14
14
|
export function resolveWorktreeArg(value: string): string {
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -2,13 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
6
|
+
import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
6
7
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
7
8
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
8
|
-
import { allRows, app, memCmd, planDir, rows } from "../store.js";
|
|
9
|
+
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
9
10
|
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
10
11
|
import { THRESHOLD } from "./rotate.js";
|
|
11
12
|
|
|
13
|
+
/** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
|
|
14
|
+
function getBranchDiffFiles(cwd: string): Set<string> {
|
|
15
|
+
try {
|
|
16
|
+
const p = Bun.spawnSync(["git", "diff", "--name-only", "dev...HEAD"], {
|
|
17
|
+
cwd,
|
|
18
|
+
stdout: "pipe",
|
|
19
|
+
stderr: "pipe",
|
|
20
|
+
});
|
|
21
|
+
if (p.exitCode !== 0) return new Set();
|
|
22
|
+
return new Set(p.stdout.toString().trim().split("\n").filter(Boolean));
|
|
23
|
+
} catch {
|
|
24
|
+
return new Set();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
const rotateLine = (n: number) =>
|
|
13
29
|
n >= THRESHOLD
|
|
14
30
|
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
@@ -23,26 +39,20 @@ const planSweepLine = () => {
|
|
|
23
39
|
: "";
|
|
24
40
|
};
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
42
|
+
// Row-list caps: no row-list section exceeds 10 (same budget as ## recent,
|
|
43
|
+
// which takes doneLines(all, 10)). Per-row text is cut at a word boundary —
|
|
44
|
+
// a kickoff line points at the row (mem find has the full text), it must not
|
|
45
|
+
// reprint it.
|
|
46
|
+
const BUGS_LIMIT = 10;
|
|
47
|
+
const BRANCH_LIMIT = 10;
|
|
48
|
+
const RECENT_OPEN_LIMIT = 10;
|
|
49
|
+
const RECENT_OPEN_TEXT = 160;
|
|
50
|
+
|
|
51
|
+
const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
|
|
52
|
+
if (text.length <= max) return text;
|
|
53
|
+
const cut = text.slice(0, max);
|
|
54
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
55
|
+
return `${lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut}…`;
|
|
46
56
|
};
|
|
47
57
|
|
|
48
58
|
export const cmdDone = () => {
|
|
@@ -234,23 +244,194 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
234
244
|
}
|
|
235
245
|
}
|
|
236
246
|
|
|
237
|
-
// ---
|
|
247
|
+
// --- suggestions (generated early so the no-args path can print next up
|
|
248
|
+
// before the recency tier, keeping it inside the 4KB cap) ---
|
|
249
|
+
|
|
250
|
+
type Suggestion = { text: string; run?: string; manual?: boolean };
|
|
251
|
+
const suggestions: Suggestion[] = [];
|
|
252
|
+
const GROUP_CAP = 3;
|
|
253
|
+
|
|
254
|
+
const truncate = (s: string, n = 80) =>
|
|
255
|
+
s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
|
256
|
+
|
|
257
|
+
// group 1 — plans: priority: high first, then the first unchecked chunk
|
|
258
|
+
const planLines: Suggestion[] = [];
|
|
259
|
+
if (existsSync(planDir)) {
|
|
260
|
+
const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
if (planLines.length >= GROUP_CAP) break;
|
|
263
|
+
const fullPath = join(planDir, file);
|
|
264
|
+
if (planFile && fullPath === planFile) continue;
|
|
265
|
+
if (hasHighPriority(fullPath)) {
|
|
266
|
+
const title = readPlanTitle(fullPath);
|
|
267
|
+
planLines.push({
|
|
268
|
+
text: `${title || file} (${file})`,
|
|
269
|
+
run: `fapony mem kickoff ${file}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (planLines.length < GROUP_CAP && planCheckboxes.length) {
|
|
275
|
+
const planName = planFile ? basename(planFile) : "plan";
|
|
276
|
+
planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
|
|
277
|
+
}
|
|
278
|
+
suggestions.push(...planLines);
|
|
279
|
+
|
|
280
|
+
// group 2 — open bugs, oldest first
|
|
281
|
+
const suggClaims = claimsOf(all);
|
|
282
|
+
const openBugs = openRows(all)
|
|
283
|
+
.filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
|
|
284
|
+
.slice(0, GROUP_CAP);
|
|
285
|
+
for (const b of openBugs) {
|
|
286
|
+
suggestions.push({
|
|
287
|
+
text: `bug #${b.id} — ${truncate(b.text)}`,
|
|
288
|
+
run: `fapony mem close ${b.id} "<msg>"`,
|
|
289
|
+
manual: true,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// group 3 — the files of the last 3 rows that carried files[]
|
|
294
|
+
const recentFiles = all
|
|
295
|
+
.filter(
|
|
296
|
+
(r): r is WorkRow =>
|
|
297
|
+
"id" in r && "files" in r && !!(r as WorkRow).files?.length,
|
|
298
|
+
)
|
|
299
|
+
.slice(-3)
|
|
300
|
+
.flatMap((r) => r.files ?? []);
|
|
301
|
+
const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
|
|
302
|
+
if (uniqueRecent.length) {
|
|
303
|
+
suggestions.push({
|
|
304
|
+
text: `แตะล่าสุด: ${uniqueRecent.join(", ")}`,
|
|
305
|
+
run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// group 4 — the lint baseline
|
|
310
|
+
try {
|
|
311
|
+
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
312
|
+
suggestions.push({
|
|
313
|
+
text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
|
|
314
|
+
run: "fapony lint-baseline --capture",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// state dir unreadable — the baseline line is a convenience, never a gate
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Print ## next up and handle --pick. */
|
|
322
|
+
const printNextUp = () => {
|
|
323
|
+
if (!suggestions.length) return;
|
|
324
|
+
console.log(`\n## next up`);
|
|
325
|
+
for (let i = 0; i < suggestions.length; i++) {
|
|
326
|
+
console.log(` [${i + 1}] ${suggestions[i].text}`);
|
|
327
|
+
if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
|
|
328
|
+
}
|
|
329
|
+
if (pickIdx !== null) {
|
|
330
|
+
if (pickIdx > suggestions.length) {
|
|
331
|
+
console.error(
|
|
332
|
+
`--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
|
|
333
|
+
);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
const picked = suggestions[pickIdx - 1];
|
|
337
|
+
if (!picked.run) {
|
|
338
|
+
console.error(
|
|
339
|
+
`--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
|
|
340
|
+
);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
if (picked.manual) {
|
|
344
|
+
console.error(
|
|
345
|
+
`--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
|
|
346
|
+
);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
const cmd = picked.run;
|
|
350
|
+
console.log(`\n> running: ${cmd}`);
|
|
351
|
+
const { execSync } = require("node:child_process");
|
|
352
|
+
try {
|
|
353
|
+
execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
|
|
354
|
+
} catch {
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// --- main output ---
|
|
361
|
+
|
|
362
|
+
// Deferred output for the no-args path — recent closes, plan sweep, rotate
|
|
363
|
+
let kickoffExtras: (() => void) | null = null;
|
|
238
364
|
|
|
239
365
|
if (!arg && !planFile) {
|
|
240
|
-
// no args =
|
|
366
|
+
// no args = ranked open rows + next up + recent closes
|
|
241
367
|
console.log(`# ${app} — ${all.length} entries`);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
368
|
+
|
|
369
|
+
const open = openRows(all);
|
|
370
|
+
const claims = claimsOf(all);
|
|
371
|
+
const diffFiles = getBranchDiffFiles(root);
|
|
372
|
+
|
|
373
|
+
// Tier 1: unclaimed bugs (always first, no matter how old) — capped: a
|
|
374
|
+
// backlog of unclosable bugs must not push the rest of the ranking out.
|
|
375
|
+
const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
|
|
376
|
+
if (bugs.length) {
|
|
377
|
+
console.log(`\n## bugs`);
|
|
378
|
+
for (const r of bugs.slice(0, BUGS_LIMIT)) console.log(fmtRow(r));
|
|
379
|
+
if (bugs.length > BUGS_LIMIT)
|
|
380
|
+
console.log(
|
|
381
|
+
`… +${bugs.length - BUGS_LIMIT} more unclosed bugs — \`fapony mem find <word>\` for the rest`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// next up BEFORE the branch/recency tiers so no row-list section can
|
|
386
|
+
// push it out — order is by actionability, never by "this tier is short".
|
|
387
|
+
printNextUp();
|
|
388
|
+
|
|
389
|
+
// Tier 2: rows whose files[] overlap with git diff --name-only dev...HEAD
|
|
390
|
+
// — capped like every other row-list section (≤10 + overflow, pointer
|
|
391
|
+
// lines via shortText): the branch diff is unbounded and mem clusters on
|
|
392
|
+
// files every real branch touches, so uncapped this reprints the log.
|
|
393
|
+
const bugIds = new Set(bugs.map((r) => r.id));
|
|
394
|
+
const diffMatched = open.filter((r) => {
|
|
395
|
+
if (bugIds.has(r.id)) return false;
|
|
396
|
+
if (diffFiles.size === 0) return false;
|
|
397
|
+
return (r.files ?? []).some((f) => diffFiles.has(f));
|
|
398
|
+
});
|
|
399
|
+
if (diffMatched.length) {
|
|
400
|
+
console.log(`\n## on this branch`);
|
|
401
|
+
for (const r of diffMatched.slice(0, BRANCH_LIMIT))
|
|
402
|
+
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
403
|
+
if (diffMatched.length > BRANCH_LIMIT)
|
|
404
|
+
console.log(
|
|
405
|
+
`… +${diffMatched.length - BRANCH_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Tier 3: rest by recency (newest first) — tie-breaker only, capped like
|
|
410
|
+
// ## recent (doneLines takes 10): decision/note can never close, so an
|
|
411
|
+
// uncapped tier reprints the whole log. Lines are pointers, not prose —
|
|
412
|
+
// cut at a word boundary and point at mem find for the rest.
|
|
413
|
+
const shown = new Set([...bugIds, ...diffMatched.map((r) => r.id)]);
|
|
414
|
+
const rest = open
|
|
415
|
+
.filter((r) => !shown.has(r.id))
|
|
416
|
+
.sort((a, b) => b.ts.localeCompare(a.ts));
|
|
417
|
+
if (rest.length) {
|
|
418
|
+
console.log(`\n## recent open`);
|
|
419
|
+
for (const r of rest.slice(0, RECENT_OPEN_LIMIT))
|
|
420
|
+
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
421
|
+
if (rest.length > RECENT_OPEN_LIMIT)
|
|
422
|
+
console.log(
|
|
423
|
+
`… +${rest.length - RECENT_OPEN_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// recent closes, plan sweep, rotate — after next up
|
|
428
|
+
kickoffExtras = () => {
|
|
429
|
+
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
430
|
+
const sweep = planSweepLine();
|
|
431
|
+
if (sweep) console.log(sweep);
|
|
432
|
+
const rotate = rotateLine(all.length);
|
|
433
|
+
if (rotate) console.log(rotate);
|
|
434
|
+
};
|
|
254
435
|
} else if (resolvedSpec) {
|
|
255
436
|
// spec.md = a brief for that spec
|
|
256
437
|
const open = openRows(all);
|
|
@@ -334,115 +515,24 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
334
515
|
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
335
516
|
);
|
|
336
517
|
}
|
|
518
|
+
} else if (planFile) {
|
|
519
|
+
const title = readPlanTitle(planFile);
|
|
520
|
+
console.log(`# ${title || basename(planFile)} — plan`);
|
|
521
|
+
if (planCheckboxes.length) {
|
|
522
|
+
console.log(
|
|
523
|
+
`\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
|
|
524
|
+
);
|
|
525
|
+
} else {
|
|
526
|
+
console.log(`\n(all chunks checked — ready to ship or archive)`);
|
|
527
|
+
}
|
|
337
528
|
} else if (arg) {
|
|
338
529
|
console.error(`no id "${arg}" in the log`);
|
|
339
530
|
process.exit(1);
|
|
340
531
|
}
|
|
341
532
|
|
|
342
|
-
//
|
|
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
|
-
}
|
|
533
|
+
// For non-no-args paths, print next up at the end
|
|
534
|
+
if (arg || planFile) printNextUp();
|
|
408
535
|
|
|
409
|
-
//
|
|
410
|
-
|
|
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
|
-
}
|
|
536
|
+
// Deferred output from no-args path (recent closes, plan sweep, rotate)
|
|
537
|
+
kickoffExtras?.();
|
|
448
538
|
};
|
package/src/mem/index.ts
CHANGED
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
// Now an export function called by fapony.ts, not a standalone script.
|
|
5
5
|
|
|
6
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";
|
|
7
|
+
import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
|
|
14
8
|
import { cmdRotate } from "./commands/rotate.js";
|
|
15
9
|
import { cmdWhere } from "./commands/where.js";
|
|
16
10
|
import {
|
|
@@ -30,7 +24,7 @@ subcommands:
|
|
|
30
24
|
add <kind> "<text>" --files f1,f2 [spec.md]
|
|
31
25
|
close <id> "<msg>" close a bug
|
|
32
26
|
find "<text>" substring-search every row
|
|
33
|
-
|
|
27
|
+
done | stale views
|
|
34
28
|
claim <id> | release <id> | synced bookkeeping
|
|
35
29
|
plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
|
|
36
30
|
plan-check <plan.md> validate a plan's frontmatter/sections
|
|
@@ -62,9 +56,6 @@ example: fapony mem close mt14 "fixed in a2c6beb"`,
|
|
|
62
56
|
find: `usage: fapony mem find "<text>"
|
|
63
57
|
substring search over every row's text/spec/ref — all kinds, no default filter
|
|
64
58
|
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
59
|
done: `usage: fapony mem done
|
|
69
60
|
closed rows with their tombstone message
|
|
70
61
|
example: fapony mem done`,
|
|
@@ -138,7 +129,7 @@ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
|
138
129
|
} else if (cmd === "rotate") {
|
|
139
130
|
cmdRotate(rest);
|
|
140
131
|
} else {
|
|
141
|
-
//
|
|
142
|
-
|
|
132
|
+
// bare `fapony mem` → kickoff (ranked session overview)
|
|
133
|
+
cmdKickoff(rest);
|
|
143
134
|
}
|
|
144
135
|
}
|
package/src/memory.ts
CHANGED
|
@@ -218,7 +218,13 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
|
|
|
218
218
|
export interface MemDirResult {
|
|
219
219
|
dir: string | null;
|
|
220
220
|
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
221
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* `.fapony/.memory/` dirs under the repo root that hold logs but are not in
|
|
223
|
+
* scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
|
|
224
|
+
* also for "repo-root"/"none" (one) — a caller that reports "nothing
|
|
225
|
+
* recorded here" is lying when a single app-scoped log exists one level
|
|
226
|
+
* down, and the honest line needs the path to say where.
|
|
227
|
+
*/
|
|
222
228
|
candidates?: string[];
|
|
223
229
|
}
|
|
224
230
|
|
|
@@ -320,20 +326,23 @@ function resolveMemDirFrom(
|
|
|
320
326
|
// Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
|
|
321
327
|
// has two or more app-scoped ones — refuse instead of silently creating a
|
|
322
328
|
// third log at the root that no app-scoped reader will ever see.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
329
|
+
// One candidate is not ambiguous, so resolution is unchanged — but it still
|
|
330
|
+
// travels back with the result: out-of-scope is not the same as absent.
|
|
331
|
+
const candidates = root ? findMemDirsUnder(root) : [];
|
|
332
|
+
if (candidates.length >= 2) {
|
|
333
|
+
return { dir: null, step: "ambiguous", candidates };
|
|
328
334
|
}
|
|
335
|
+
const outOfScope = candidates.length ? { candidates } : {};
|
|
329
336
|
|
|
330
337
|
// Step 4: <repo root>/.fapony/.memory/ — where a new log is created
|
|
331
338
|
if (root) {
|
|
332
339
|
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
333
|
-
if (existsSync(rootDir))
|
|
340
|
+
if (existsSync(rootDir)) {
|
|
341
|
+
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
342
|
+
}
|
|
334
343
|
}
|
|
335
344
|
|
|
336
|
-
return { dir: null, step: "none" };
|
|
345
|
+
return { dir: null, step: "none", ...outOfScope };
|
|
337
346
|
}
|
|
338
347
|
|
|
339
348
|
export function resolveMemDir(
|