fapony 0.3.4 → 0.3.5
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 +7 -1
- package/package.json +4 -5
- package/src/adapters/cli.ts +12 -7
- package/src/adapters/hooks/index.ts +1 -0
- package/src/adapters/hooks/stop.ts +138 -6
- package/src/commands.ts +180 -0
- package/src/hook.ts +1 -0
- package/src/mem/commands/plan.ts +127 -0
- package/src/mem/commands/read.ts +53 -12
- package/src/mem/index.ts +60 -1
- package/src/memory.ts +2 -1
- package/src/seed/plan-seed.ts +118 -28
- package/src/update.ts +1 -1
- package/src/test.ts +0 -2
package/README.md
CHANGED
|
@@ -476,6 +476,13 @@ fapony mem where # show the resolved mem dir and which
|
|
|
476
476
|
fapony mem done | stale # views
|
|
477
477
|
fapony debt [--id <convention>] [--where <path>] # ไฟล์ไหนยังไม่ย้ายไป convention ที่ประกาศไว้ (live, read-only)
|
|
478
478
|
fapony lint-baseline [--cmd ...] [--diff] # separate "already red" from "I made it red"
|
|
479
|
+
|
|
480
|
+
# Hooks (wired by `fapony install`, not run by hand)
|
|
481
|
+
fapony hook-stop # Stop hook: block turns with commits but no mem row
|
|
482
|
+
fapony hook-read-hint # read/re-read annotations
|
|
483
|
+
fapony hook-edit-hint # importer count before editing shape
|
|
484
|
+
fapony hook-mv-guard # deny raw git mv of plan files into done/
|
|
485
|
+
fapony hook-session-start # SessionStart: kickoff into context
|
|
479
486
|
```
|
|
480
487
|
|
|
481
488
|
*When* to call `mem add` is your project's call, not fapony's — write it in your own
|
|
@@ -501,7 +508,6 @@ fapony install --dry-run # show what would happen without writi
|
|
|
501
508
|
fapony setup # interactive wizard: config + scaffold in one step
|
|
502
509
|
fapony update # self-update via git pull
|
|
503
510
|
fapony telemetry show|send # opt-in only, default off — see https://github.com/kire21b/fapony/blob/main/TELEMETRY.md
|
|
504
|
-
fapony test # self-check
|
|
505
511
|
```
|
|
506
512
|
|
|
507
513
|
## Config
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -29,11 +29,10 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"lint": "biome check .",
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
|
-
"test": "bun
|
|
33
|
-
"test:
|
|
34
|
-
"test:one": "bun scripts/test-one.ts",
|
|
32
|
+
"test": "bun test --timeout 20000 --parallel",
|
|
33
|
+
"test:changed": "bun test --timeout 20000 --changed",
|
|
35
34
|
"knip": "bunx knip@6 --exclude types,nsTypes || true",
|
|
36
|
-
"check": "bun run lint && bun run typecheck && bun
|
|
35
|
+
"check": "bun run lint && bun run typecheck && bun test --timeout 20000 --parallel",
|
|
37
36
|
"prepublishOnly": "bash scripts/smoke-publish.sh",
|
|
38
37
|
"release": "git checkout main && git pull --ff-only && npm version patch -m 'release v%s' && git push origin main --follow-tags",
|
|
39
38
|
"overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
|
package/src/adapters/cli.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { cmdAnalyze } from "../analyze.js";
|
|
9
|
+
import { renderUsage, suggestCommand } from "../commands.js";
|
|
9
10
|
import { cmdDebt } from "../debt/cli.js";
|
|
10
11
|
import { cmdDigest } from "../digest/cli.js";
|
|
11
12
|
import { cmdInit } from "../init.js";
|
|
12
13
|
import { cmdInitMem } from "../init-mem.js";
|
|
13
14
|
import { cmdInstall } from "../install.js";
|
|
14
15
|
import { cmdLintBaseline } from "../lint-baseline.js";
|
|
15
|
-
import { cmdMem } from "../mem/index.js";
|
|
16
|
+
import { cmdMem, MEM_SUBCOMMANDS } from "../mem/index.js";
|
|
16
17
|
import { initStore } from "../mem/store.js";
|
|
17
18
|
import { cmdPriceScan } from "../price/index.js";
|
|
18
19
|
import { cmdReport, cmdReportWeb } from "../report/index.js";
|
|
@@ -35,6 +36,11 @@ import { cmdMcp } from "./mcp/transport.js";
|
|
|
35
36
|
export async function cliMain(): Promise<void> {
|
|
36
37
|
const [cmd, ...a] = process.argv.slice(2);
|
|
37
38
|
|
|
39
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
40
|
+
console.log(renderUsage());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
if (cmd === "analyze") {
|
|
39
45
|
cmdAnalyze(a);
|
|
40
46
|
} else if (cmd === "debt") {
|
|
@@ -110,14 +116,13 @@ export async function cliMain(): Promise<void> {
|
|
|
110
116
|
await cmdPriceScan(a);
|
|
111
117
|
} else if (cmd === "usage-web") {
|
|
112
118
|
cmdUsageWeb(a);
|
|
113
|
-
} else if (cmd === "test") {
|
|
114
|
-
const { cmdTest } = await import("../test.js");
|
|
115
|
-
await cmdTest();
|
|
116
119
|
} else {
|
|
117
120
|
console.error(`fapony: unknown command "${cmd ?? ""}"`);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
const hints = suggestCommand(cmd ?? "", MEM_SUBCOMMANDS);
|
|
122
|
+
if (hints.length > 0) {
|
|
123
|
+
console.error(`did you mean ${hints.map((h) => `"${h}"`).join(" or ")}?`);
|
|
124
|
+
}
|
|
125
|
+
console.error(`usage: fapony <command> [args] — see "fapony --help"`);
|
|
121
126
|
process.exit(1);
|
|
122
127
|
}
|
|
123
128
|
}
|
|
@@ -120,7 +120,8 @@ export function normalizeStopInput(
|
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
122
|
* Pure decision: block when this session produced commits but no mem row
|
|
123
|
-
* newer than session start exists
|
|
123
|
+
* newer than session start exists, or when the agent announced a bug but
|
|
124
|
+
* filed no kind:bug row.
|
|
124
125
|
*/
|
|
125
126
|
export function decideStop(opts: {
|
|
126
127
|
stopHookActive: boolean;
|
|
@@ -130,9 +131,23 @@ export function decideStop(opts: {
|
|
|
130
131
|
commitList?: string[];
|
|
131
132
|
memLastTs?: string | null;
|
|
132
133
|
memCandidates?: string[];
|
|
134
|
+
bugSignal?: string | null;
|
|
135
|
+
bugRowSinceStart?: boolean;
|
|
133
136
|
}): string | null {
|
|
134
137
|
if (opts.stopHookActive) return null;
|
|
135
138
|
if (!opts.worktree) return null;
|
|
139
|
+
|
|
140
|
+
// --- Bug-signal block (independent of commits) ---
|
|
141
|
+
if (opts.bugSignal && !opts.bugRowSinceStart) {
|
|
142
|
+
return [
|
|
143
|
+
`This turn reported a bug ("${opts.bugSignal}") but no mem row with kind:bug exists for this session.`,
|
|
144
|
+
`A bug described in chat is lost when the room closes — a decision row does not surface it.`,
|
|
145
|
+
` fapony mem add bug "<what is broken>" --files <files>`,
|
|
146
|
+
`Already filed elsewhere, or not a bug? End the turn again — this fires once per session.`,
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Commit block (original) ---
|
|
136
151
|
if (opts.commits < 1) return null;
|
|
137
152
|
if (!opts.memLastTs && !opts.memCandidates?.length) return null;
|
|
138
153
|
if (opts.since && opts.memLastTs) {
|
|
@@ -164,6 +179,11 @@ export function decideStop(opts: {
|
|
|
164
179
|
`${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
|
|
165
180
|
`files[] is required — a row without it is unfindable when you touch that file next session.`,
|
|
166
181
|
);
|
|
182
|
+
if (opts.bugSignal) {
|
|
183
|
+
lines.push(
|
|
184
|
+
`This turn announced a bug ("${opts.bugSignal}") — use kind:bug, not decision.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
167
187
|
return lines.join("\n");
|
|
168
188
|
}
|
|
169
189
|
|
|
@@ -182,6 +202,7 @@ const STOP_BLOCK_DIR = "stop-block";
|
|
|
182
202
|
interface StopBlockRow {
|
|
183
203
|
ts: string;
|
|
184
204
|
worktree: string;
|
|
205
|
+
kind?: string;
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/** Absolute path of a session's block log — may not exist. */
|
|
@@ -192,12 +213,16 @@ export function stopBlockPath(session: string): string {
|
|
|
192
213
|
}
|
|
193
214
|
|
|
194
215
|
/**
|
|
195
|
-
* True when this session already blocked for this worktree — the caller
|
|
196
|
-
* lets the turn end. Records the block when it has not.
|
|
216
|
+
* True when this session already blocked for this worktree + kind — the caller
|
|
217
|
+
* then lets the turn end. Records the block when it has not.
|
|
218
|
+
*
|
|
219
|
+
* `kind` defaults to `"commit"` for backwards compatibility. Bug blocks use
|
|
220
|
+
* `"bug"` so they don't consume the commit block's quota.
|
|
197
221
|
*/
|
|
198
222
|
export function stopBlockedBefore(
|
|
199
223
|
session: string | null,
|
|
200
224
|
worktree: string,
|
|
225
|
+
kind: string = "commit",
|
|
201
226
|
): boolean {
|
|
202
227
|
if (!session) return false;
|
|
203
228
|
const path = stopBlockPath(session);
|
|
@@ -206,7 +231,8 @@ export function stopBlockedBefore(
|
|
|
206
231
|
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
207
232
|
if (!line) continue;
|
|
208
233
|
try {
|
|
209
|
-
|
|
234
|
+
const row = JSON.parse(line) as StopBlockRow;
|
|
235
|
+
if (row.worktree === worktree && (row.kind ?? "commit") === kind) {
|
|
210
236
|
return true;
|
|
211
237
|
}
|
|
212
238
|
} catch {
|
|
@@ -216,7 +242,7 @@ export function stopBlockedBefore(
|
|
|
216
242
|
}
|
|
217
243
|
const dir = join(path, "..");
|
|
218
244
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
219
|
-
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
245
|
+
const row: StopBlockRow = { ts: new Date().toISOString(), worktree, kind };
|
|
220
246
|
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
221
247
|
} catch {
|
|
222
248
|
return false;
|
|
@@ -224,6 +250,96 @@ export function stopBlockedBefore(
|
|
|
224
250
|
return false;
|
|
225
251
|
}
|
|
226
252
|
|
|
253
|
+
// --- Bug-signal detection ---
|
|
254
|
+
|
|
255
|
+
/** Announcement words that indicate the agent found a bug — not symptom words. */
|
|
256
|
+
const BUG_MARKERS: RegExp[] = [/เจอบั๊ก/];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Scan assistant text from a Claude transcript for bug markers. Reads only
|
|
260
|
+
* the tail of the file (last 200KB) to avoid parsing the full transcript.
|
|
261
|
+
* Returns the first matched marker word, or null.
|
|
262
|
+
*/
|
|
263
|
+
export function bugSignalFromTranscript(
|
|
264
|
+
transcriptPath: string,
|
|
265
|
+
sinceMs: number,
|
|
266
|
+
): string | null {
|
|
267
|
+
try {
|
|
268
|
+
const stat = statSync(transcriptPath);
|
|
269
|
+
// Cap at 10MB — hook must not stall turn-end
|
|
270
|
+
if (stat.size > 10 * 1024 * 1024) return null;
|
|
271
|
+
|
|
272
|
+
// Read tail to avoid parsing the full transcript
|
|
273
|
+
const tailBytes = Math.min(stat.size, 200 * 1024);
|
|
274
|
+
const fd = require("node:fs").openSync(transcriptPath, "r");
|
|
275
|
+
const buf = Buffer.alloc(tailBytes);
|
|
276
|
+
require("node:fs").readSync(
|
|
277
|
+
fd,
|
|
278
|
+
buf,
|
|
279
|
+
0,
|
|
280
|
+
tailBytes,
|
|
281
|
+
Math.max(0, stat.size - tailBytes),
|
|
282
|
+
);
|
|
283
|
+
require("node:fs").closeSync(fd);
|
|
284
|
+
|
|
285
|
+
const tail = buf.toString("utf-8");
|
|
286
|
+
// When reading from the middle of a large file, the first line is partial
|
|
287
|
+
const readingMidFile = tailBytes < stat.size;
|
|
288
|
+
const startIdx = readingMidFile ? tail.indexOf("\n") : -1;
|
|
289
|
+
const lines =
|
|
290
|
+
startIdx >= 0 ? tail.slice(startIdx + 1).split("\n") : tail.split("\n");
|
|
291
|
+
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
if (!line) continue;
|
|
294
|
+
// Transcript tail: untyped JSONL — the structural type below names only
|
|
295
|
+
// the fields this scan reads (biome noExplicitAny: no `any` annotation).
|
|
296
|
+
let o: {
|
|
297
|
+
message?: {
|
|
298
|
+
role?: string;
|
|
299
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
300
|
+
created_at?: string;
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
try {
|
|
304
|
+
o = JSON.parse(line);
|
|
305
|
+
} catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const m = o.message;
|
|
309
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
310
|
+
// Skip messages older than session start
|
|
311
|
+
if (m.created_at) {
|
|
312
|
+
const msgMs = new Date(m.created_at).getTime();
|
|
313
|
+
if (!Number.isNaN(msgMs) && msgMs < sinceMs) continue;
|
|
314
|
+
}
|
|
315
|
+
for (const b of m.content) {
|
|
316
|
+
if (b.type !== "text" || typeof b.text !== "string") continue;
|
|
317
|
+
for (const re of BUG_MARKERS) {
|
|
318
|
+
if (re.test(b.text)) {
|
|
319
|
+
const match = b.text.match(re);
|
|
320
|
+
return match?.[0] ?? null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* True when the mem log has at least one kind:bug row with ts >= since.
|
|
333
|
+
*/
|
|
334
|
+
function hasBugRowSince(worktree: string, since: string): boolean {
|
|
335
|
+
try {
|
|
336
|
+
const { rows } = readMemLog(worktree, since);
|
|
337
|
+
return rows.some((r) => r.kind === "bug");
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
227
343
|
function git(args: string[], cwd: string): string | null {
|
|
228
344
|
try {
|
|
229
345
|
const p = Bun.spawnSync(["git", ...args], {
|
|
@@ -262,6 +378,8 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
262
378
|
let commitList: string[] = [];
|
|
263
379
|
let memLastTs: string | null = null;
|
|
264
380
|
let memCandidates: string[] = [];
|
|
381
|
+
let bugSignal: string | null = null;
|
|
382
|
+
let bugRowSinceStart = false;
|
|
265
383
|
if (worktree && since) {
|
|
266
384
|
const log = git(
|
|
267
385
|
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
@@ -276,6 +394,14 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
276
394
|
} catch {
|
|
277
395
|
memLastTs = null;
|
|
278
396
|
}
|
|
397
|
+
// Bug-signal detection (independent of commits)
|
|
398
|
+
if (norm.transcriptPath && process.env.FAPONY_NO_BUG_BLOCK !== "1") {
|
|
399
|
+
const sinceMs = hookTsMs(since);
|
|
400
|
+
if (!Number.isNaN(sinceMs)) {
|
|
401
|
+
bugSignal = bugSignalFromTranscript(norm.transcriptPath, sinceMs);
|
|
402
|
+
if (bugSignal) bugRowSinceStart = hasBugRowSince(worktree, since);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
279
405
|
}
|
|
280
406
|
|
|
281
407
|
reason = decideStop({
|
|
@@ -286,11 +412,17 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
286
412
|
commitList,
|
|
287
413
|
memLastTs,
|
|
288
414
|
memCandidates,
|
|
415
|
+
bugSignal,
|
|
416
|
+
bugRowSinceStart,
|
|
289
417
|
});
|
|
290
418
|
if (
|
|
291
419
|
reason &&
|
|
292
420
|
worktree &&
|
|
293
|
-
stopBlockedBefore(
|
|
421
|
+
stopBlockedBefore(
|
|
422
|
+
norm.transcriptPath,
|
|
423
|
+
worktree,
|
|
424
|
+
bugSignal ? "bug" : "commit",
|
|
425
|
+
)
|
|
294
426
|
) {
|
|
295
427
|
reason = null;
|
|
296
428
|
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/commands.ts — the single source of truth for top-level command names.
|
|
2
|
+
//
|
|
3
|
+
// Dispatch stays an if-chain in src/adapters/cli.ts (rule 1: this is a data
|
|
4
|
+
// table, not a framework — no registry class, no plugin hooks). This table
|
|
5
|
+
// owns name + group + one-line summary; `fapony --help` renders from it and
|
|
6
|
+
// test/docs.test.ts fails when a name here is missing from the docs, so the
|
|
7
|
+
// four copies (if-chain, usage, CLAUDE.md, README) cannot drift silently.
|
|
8
|
+
// Zero imports — cli.ts loads this on every startup (≤ ~100ms budget).
|
|
9
|
+
|
|
10
|
+
export type CommandGroup =
|
|
11
|
+
| "core"
|
|
12
|
+
| "usage"
|
|
13
|
+
| "lookup"
|
|
14
|
+
| "hooks"
|
|
15
|
+
| "setup"
|
|
16
|
+
| "frozen";
|
|
17
|
+
|
|
18
|
+
export interface CommandInfo {
|
|
19
|
+
name: string;
|
|
20
|
+
group: CommandGroup;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GROUP_ORDER: CommandGroup[] = [
|
|
25
|
+
"core",
|
|
26
|
+
"usage",
|
|
27
|
+
"lookup",
|
|
28
|
+
"hooks",
|
|
29
|
+
"setup",
|
|
30
|
+
"frozen",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const COMMANDS: CommandInfo[] = [
|
|
34
|
+
{
|
|
35
|
+
name: "mem",
|
|
36
|
+
group: "core",
|
|
37
|
+
summary: "project pain memory: decisions, bugs, notes",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "debt",
|
|
41
|
+
group: "core",
|
|
42
|
+
summary: "which files haven't migrated to a declared convention",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "lint-baseline",
|
|
46
|
+
group: "core",
|
|
47
|
+
summary: 'separate "already red" from "I made it red"',
|
|
48
|
+
},
|
|
49
|
+
{ name: "init-mem", group: "core", summary: "delete legacy .memory/ dirs" },
|
|
50
|
+
{
|
|
51
|
+
name: "digest",
|
|
52
|
+
group: "core",
|
|
53
|
+
summary: "single-page summary from what's on disk",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "usage-scan",
|
|
57
|
+
group: "usage",
|
|
58
|
+
summary: "scan session logs into the cache",
|
|
59
|
+
},
|
|
60
|
+
{ name: "usage-web", group: "usage", summary: "usage dashboard from cache" },
|
|
61
|
+
{
|
|
62
|
+
name: "price-scan",
|
|
63
|
+
group: "usage",
|
|
64
|
+
summary: "refresh the model price table",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "analyze",
|
|
68
|
+
group: "lookup",
|
|
69
|
+
summary: "live repo graph: hubs, orphans, cycles",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "review-seed",
|
|
73
|
+
group: "lookup",
|
|
74
|
+
summary: "read-only scope facts for a review",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "plan-seed",
|
|
78
|
+
group: "lookup",
|
|
79
|
+
summary: "write PLAN (+SPEC) with capped sections",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "mcp",
|
|
83
|
+
group: "hooks",
|
|
84
|
+
summary: "MCP server (stdio JSON-RPC, 3 tools)",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "hook-stop",
|
|
88
|
+
group: "hooks",
|
|
89
|
+
summary: "Stop hook: block turns with commits but no mem row",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "hook-read-hint",
|
|
93
|
+
group: "hooks",
|
|
94
|
+
summary: "read/re-read annotations",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "hook-edit-hint",
|
|
98
|
+
group: "hooks",
|
|
99
|
+
summary: "importer count before editing shape",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "hook-mv-guard",
|
|
103
|
+
group: "hooks",
|
|
104
|
+
summary: "deny raw git mv of plan files into done/",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "hook-session-start",
|
|
108
|
+
group: "hooks",
|
|
109
|
+
summary: "SessionStart: kickoff into context",
|
|
110
|
+
},
|
|
111
|
+
{ name: "init", group: "setup", summary: "scaffold .fapony/ in a worktree" },
|
|
112
|
+
{
|
|
113
|
+
name: "install",
|
|
114
|
+
group: "setup",
|
|
115
|
+
summary: "wire MCP + skills into clients",
|
|
116
|
+
},
|
|
117
|
+
{ name: "setup", group: "setup", summary: "interactive wizard" },
|
|
118
|
+
{ name: "update", group: "setup", summary: "self-update via git pull" },
|
|
119
|
+
{ name: "telemetry", group: "setup", summary: "opt-in telemetry show/send" },
|
|
120
|
+
{
|
|
121
|
+
name: "stats",
|
|
122
|
+
group: "frozen",
|
|
123
|
+
summary: "frozen-ledger KPIs (reads history only)",
|
|
124
|
+
},
|
|
125
|
+
{ name: "report", group: "frozen", summary: "verification report for a run" },
|
|
126
|
+
{ name: "report-web", group: "frozen", summary: "static HTML report page" },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// Grouped names only — the shape PLAN-seed-and-surface §7 picked. Starts with
|
|
130
|
+
// a `usage: fapony` line: scripts/smoke-publish.sh greps for it on a no-arg run.
|
|
131
|
+
export const renderUsage = (): string => {
|
|
132
|
+
const lines = [
|
|
133
|
+
"fapony — project pain memory for agent-written code",
|
|
134
|
+
"usage: fapony <command> [args]",
|
|
135
|
+
"",
|
|
136
|
+
];
|
|
137
|
+
for (const g of GROUP_ORDER) {
|
|
138
|
+
const names = COMMANDS.filter((c) => c.group === g).map((c) => c.name);
|
|
139
|
+
lines.push(` ${g.padEnd(8)}${names.join(" ")}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(
|
|
142
|
+
"",
|
|
143
|
+
" fapony mem <sub> --help per-subcommand details (only mem has --help)",
|
|
144
|
+
);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Classic two-row Levenshtein — the did-you-mean behind unknown-command errors.
|
|
149
|
+
export const levenshtein = (a: string, b: string): number => {
|
|
150
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
151
|
+
for (let i = 1; i <= a.length; i++) {
|
|
152
|
+
const cur = [i];
|
|
153
|
+
for (let j = 1; j <= b.length; j++) {
|
|
154
|
+
cur[j] = Math.min(
|
|
155
|
+
prev[j] + 1,
|
|
156
|
+
cur[j - 1] + 1,
|
|
157
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
prev = cur;
|
|
161
|
+
}
|
|
162
|
+
return prev[b.length];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Up to 3 suggestions within distance ≤ 2, against top-level names and mem
|
|
166
|
+
// subs (so `fapony plan-check` offers `fapony mem plan-check`). Sorted by
|
|
167
|
+
// distance, then alphabetically — deterministic for tests.
|
|
168
|
+
export const suggestCommand = (cmd: string, memSubs: string[]): string[] => {
|
|
169
|
+
const scored: Array<{ text: string; d: number }> = [];
|
|
170
|
+
for (const c of COMMANDS) {
|
|
171
|
+
const d = levenshtein(cmd, c.name);
|
|
172
|
+
if (d <= 2) scored.push({ text: `fapony ${c.name}`, d });
|
|
173
|
+
}
|
|
174
|
+
for (const s of memSubs) {
|
|
175
|
+
const d = levenshtein(cmd, s);
|
|
176
|
+
if (d <= 2) scored.push({ text: `fapony mem ${s}`, d });
|
|
177
|
+
}
|
|
178
|
+
scored.sort((x, y) => x.d - y.d || (x.text < y.text ? -1 : 1));
|
|
179
|
+
return scored.slice(0, 3).map((s) => s.text);
|
|
180
|
+
};
|
package/src/hook.ts
CHANGED
package/src/mem/commands/plan.ts
CHANGED
|
@@ -323,7 +323,93 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
323
323
|
};
|
|
324
324
|
|
|
325
325
|
// plan-check — list active PLANs + detect shipped-not-moved + broken links
|
|
326
|
+
// + verify the commits ticked chunks cite (chunk 8, PLAN-seed-and-surface)
|
|
326
327
|
// exit 0 = clean, 1 = issues found
|
|
328
|
+
|
|
329
|
+
// A ticked chunk cites the commit that closed it — a sha git cannot find on
|
|
330
|
+
// this HEAD means the "done" mark proves nothing. git is the judge, not the
|
|
331
|
+
// regex: a 7-hex word that is no object at all (deadbee) is a plain word,
|
|
332
|
+
// never an issue. A sha that IS an object but not a commit (blob/tree)
|
|
333
|
+
// counts as missing — "no such commit" is literally true for it.
|
|
334
|
+
// Standalone short shas only — lookarounds (not \b) so a 40-char sha never
|
|
335
|
+
// matches on its tail: git resolves leading prefixes, a trailing slice would
|
|
336
|
+
// false-positive as missing.
|
|
337
|
+
export const SHA_RE = /(?<![0-9a-f])[0-9a-f]{7,12}(?![0-9a-f])/g;
|
|
338
|
+
|
|
339
|
+
export const extractShas = (line: string): string[] => line.match(SHA_RE) ?? [];
|
|
340
|
+
|
|
341
|
+
const gitOk = (args: string[], cwd: string): boolean => {
|
|
342
|
+
try {
|
|
343
|
+
return (
|
|
344
|
+
Bun.spawnSync(["git", ...args], {
|
|
345
|
+
cwd,
|
|
346
|
+
stdout: "ignore",
|
|
347
|
+
stderr: "ignore",
|
|
348
|
+
}).exitCode === 0
|
|
349
|
+
);
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
export const isCommitObject = (sha: string, cwd: string): boolean =>
|
|
356
|
+
gitOk(["cat-file", "-e", `${sha}^{commit}`], cwd);
|
|
357
|
+
|
|
358
|
+
const isAnyObject = (sha: string, cwd: string): boolean =>
|
|
359
|
+
gitOk(["cat-file", "-e", sha], cwd);
|
|
360
|
+
|
|
361
|
+
export const isAncestorOfHead = (sha: string, cwd: string): boolean =>
|
|
362
|
+
gitOk(["merge-base", "--is-ancestor", sha, "HEAD"], cwd);
|
|
363
|
+
|
|
364
|
+
// Per ticked line: which cited shas fail, plus how many shas the line cites
|
|
365
|
+
// at all (cited). A hex word git never heard of is a plain word (deadbee,
|
|
366
|
+
// the "feedbac" inside "feedback") — UNLESS it sits in a paren group with a
|
|
367
|
+
// sha git knows, in which case the author cited it as a commit: the fixture
|
|
368
|
+
// is (e898877 + e307fe6), where e898877 resolves to no object at all yet is
|
|
369
|
+
// unmistakably a citation, not prose.
|
|
370
|
+
export const checkTickedLine = (
|
|
371
|
+
line: string,
|
|
372
|
+
cwd: string,
|
|
373
|
+
): { missing: string[]; diverged: string[]; cited: number } => {
|
|
374
|
+
const missing: string[] = [];
|
|
375
|
+
const diverged: string[] = [];
|
|
376
|
+
const status = new Map<string, "commit" | "object" | "word">();
|
|
377
|
+
for (const sha of extractShas(line)) {
|
|
378
|
+
if (!status.has(sha)) {
|
|
379
|
+
status.set(
|
|
380
|
+
sha,
|
|
381
|
+
isCommitObject(sha, cwd)
|
|
382
|
+
? "commit"
|
|
383
|
+
: isAnyObject(sha, cwd)
|
|
384
|
+
? "object"
|
|
385
|
+
: "word",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const known = new Set(
|
|
390
|
+
[...status].filter(([, s]) => s !== "word").map(([k]) => k),
|
|
391
|
+
);
|
|
392
|
+
const citedInGroup = new Set<string>();
|
|
393
|
+
for (const g of line.match(/\([^)]*\)/g) ?? []) {
|
|
394
|
+
const gs = extractShas(g);
|
|
395
|
+
if (gs.some((s) => known.has(s))) for (const s of gs) citedInGroup.add(s);
|
|
396
|
+
}
|
|
397
|
+
let cited = 0;
|
|
398
|
+
for (const [sha, st] of status) {
|
|
399
|
+
if (st === "commit") {
|
|
400
|
+
cited++;
|
|
401
|
+
if (!isAncestorOfHead(sha, cwd)) diverged.push(sha);
|
|
402
|
+
} else if (st === "object") {
|
|
403
|
+
cited++;
|
|
404
|
+
missing.push(sha);
|
|
405
|
+
} else if (citedInGroup.has(sha)) {
|
|
406
|
+
cited++;
|
|
407
|
+
missing.push(sha);
|
|
408
|
+
}
|
|
409
|
+
// else: a plain word that happens to be hex — skip
|
|
410
|
+
}
|
|
411
|
+
return { missing, diverged, cited };
|
|
412
|
+
};
|
|
327
413
|
export const cmdPlanCheck = (a: string[]) => {
|
|
328
414
|
const quiet = a.includes("--quiet");
|
|
329
415
|
const dir = planDir;
|
|
@@ -375,6 +461,47 @@ export const cmdPlanCheck = (a: string[]) => {
|
|
|
375
461
|
}
|
|
376
462
|
}
|
|
377
463
|
|
|
464
|
+
// 4) Ticked-chunk sha check — a ticked chunk that cites a commit must cite
|
|
465
|
+
// one git finds on this HEAD. Scans plan/ AND done/: done/ files are the
|
|
466
|
+
// shipped record, and the known-stale shas all live there — active-only
|
|
467
|
+
// would see zero. No sha = no check (chunks that close with "defer" have
|
|
468
|
+
// no commit); the summary line reports the ratio instead of flagging.
|
|
469
|
+
let closed = 0;
|
|
470
|
+
let citing = 0;
|
|
471
|
+
let verified = 0;
|
|
472
|
+
const shaFiles = [
|
|
473
|
+
...new Set([...active, ...(existsSync(doneDir) ? mdFiles(doneDir) : [])]),
|
|
474
|
+
];
|
|
475
|
+
for (const f of shaFiles) {
|
|
476
|
+
const relPath = relative(planBase, f);
|
|
477
|
+
const lines = readFileSync(f, "utf8").split("\n");
|
|
478
|
+
lines.forEach((line, i) => {
|
|
479
|
+
if (!/^\s*-\s\[x\]/.test(line)) return;
|
|
480
|
+
closed++;
|
|
481
|
+
const { missing, diverged, cited } = checkTickedLine(line, root);
|
|
482
|
+
if (cited > 0) {
|
|
483
|
+
citing++;
|
|
484
|
+
if (!missing.length && !diverged.length) verified++;
|
|
485
|
+
}
|
|
486
|
+
for (const sha of missing) {
|
|
487
|
+
issues.push(
|
|
488
|
+
`${relPath}:${i + 1} — ticked chunk cites ${sha} but git has no such commit\n fix: correct the sha or leave the chunk unticked`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
for (const sha of diverged) {
|
|
492
|
+
issues.push(
|
|
493
|
+
`${relPath}:${i + 1} — ticked chunk cites ${sha} which is not an ancestor of HEAD (rebased away?)\n fix: point at the surviving commit or leave the chunk unticked`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!quiet) {
|
|
500
|
+
console.log(
|
|
501
|
+
`closed chunks: ${closed} · citing a commit: ${citing} · verified: ${verified}`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
378
505
|
if (issues.length === 0) {
|
|
379
506
|
if (!quiet) console.log("✅ clean");
|
|
380
507
|
process.exit(0);
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
|
7
7
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
8
8
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
9
9
|
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
10
|
-
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
10
|
+
import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
11
11
|
import { THRESHOLD } from "./rotate.js";
|
|
12
12
|
|
|
13
13
|
/** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
|
|
@@ -93,30 +93,56 @@ export const cmdFind = (a: string[]) => {
|
|
|
93
93
|
if (!hits.length) console.log("(no matches)");
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
/** Read a plan file and extract unchecked
|
|
97
|
-
const
|
|
96
|
+
/** Read a plan file and extract checked + unchecked items from the first ## section. */
|
|
97
|
+
const readPlanSectionItems = (
|
|
98
|
+
planPath: string,
|
|
99
|
+
): { checked: string[]; unchecked: string[] } => {
|
|
98
100
|
try {
|
|
99
101
|
const text = readFileSync(planPath, "utf8");
|
|
100
102
|
// Skip frontmatter
|
|
101
103
|
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
102
104
|
// Find the first ## section (TL;DR)
|
|
103
105
|
const start = body.search(/^##\s+/m);
|
|
104
|
-
if (start < 0) return [];
|
|
106
|
+
if (start < 0) return { checked: [], unchecked: [] };
|
|
105
107
|
const rest = body.slice(start);
|
|
106
108
|
const next = rest.slice(3).search(/^##\s+/m);
|
|
107
109
|
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
108
|
-
// Extract
|
|
109
|
-
const
|
|
110
|
+
// Extract checkboxes — ticked and unticked
|
|
111
|
+
const checked: string[] = [];
|
|
112
|
+
const unchecked: string[] = [];
|
|
110
113
|
for (const line of block.split("\n")) {
|
|
111
|
-
|
|
112
|
-
if (m)
|
|
114
|
+
let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
|
|
115
|
+
if (m) {
|
|
116
|
+
unchecked.push(m[1].trim());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
|
|
120
|
+
if (m) checked.push(m[1].trim());
|
|
113
121
|
}
|
|
114
|
-
return
|
|
122
|
+
return { checked, unchecked };
|
|
115
123
|
} catch {
|
|
116
|
-
return [];
|
|
124
|
+
return { checked: [], unchecked: [] };
|
|
117
125
|
}
|
|
118
126
|
};
|
|
119
127
|
|
|
128
|
+
// Chunk 9 (PLAN-seed-and-surface): one line under the next-chunk display
|
|
129
|
+
// saying whether the last ticked chunk actually closed. Verified shas stay
|
|
130
|
+
// silent; only the newest ticked chunk is ever mentioned — one line per
|
|
131
|
+
// kickoff, never a list (else vela's 64/72 sha-less chunks become noise).
|
|
132
|
+
const closureHint = (checked: string[]): string | null => {
|
|
133
|
+
const last = checked[checked.length - 1];
|
|
134
|
+
if (!last) return null;
|
|
135
|
+
const { missing, diverged, cited } = checkTickedLine(last, root);
|
|
136
|
+
const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
|
|
137
|
+
if (missing.length)
|
|
138
|
+
return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
|
|
139
|
+
if (diverged.length)
|
|
140
|
+
return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
|
|
141
|
+
if (!cited)
|
|
142
|
+
return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
|
|
120
146
|
/** Check if a plan has `priority: high` in its frontmatter. */
|
|
121
147
|
const hasHighPriority = (planPath: string): boolean => {
|
|
122
148
|
try {
|
|
@@ -200,6 +226,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
200
226
|
// If no arg or arg didn't match an id/spec, check if it's a plan file
|
|
201
227
|
let planFile: string | null = null;
|
|
202
228
|
let planCheckboxes: string[] = [];
|
|
229
|
+
let planChecked: string[] = [];
|
|
203
230
|
|
|
204
231
|
if (arg && !resolvedSpec && !target) {
|
|
205
232
|
if (matches.length === 1) {
|
|
@@ -233,14 +260,18 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
233
260
|
|
|
234
261
|
// Read plan file if we found one (or if a .md arg matched a spec)
|
|
235
262
|
if (planFile) {
|
|
236
|
-
|
|
263
|
+
const items = readPlanSectionItems(planFile);
|
|
264
|
+
planCheckboxes = items.unchecked;
|
|
265
|
+
planChecked = items.checked;
|
|
237
266
|
} else if (arg && resolvedSpec && arg.endsWith(".md")) {
|
|
238
267
|
// The spec resolved from the log might be a plan file — try to read it
|
|
239
268
|
const dir = planDir;
|
|
240
269
|
const byName = join(dir, basename(arg));
|
|
241
270
|
if (existsSync(byName)) {
|
|
242
271
|
planFile = byName;
|
|
243
|
-
|
|
272
|
+
const items = readPlanSectionItems(planFile);
|
|
273
|
+
planCheckboxes = items.unchecked;
|
|
274
|
+
planChecked = items.checked;
|
|
244
275
|
}
|
|
245
276
|
}
|
|
246
277
|
|
|
@@ -466,6 +497,12 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
466
497
|
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
467
498
|
console.log("(no entries for this spec)");
|
|
468
499
|
}
|
|
500
|
+
// The spec is also a plan file on disk — say whether the last ticked
|
|
501
|
+
// chunk actually closed (chunk 9). Silent when it verifies.
|
|
502
|
+
if (planFile) {
|
|
503
|
+
const hint = closureHint(planChecked);
|
|
504
|
+
if (hint) console.log(`\n${hint}`);
|
|
505
|
+
}
|
|
469
506
|
} else if (target) {
|
|
470
507
|
// id = a brief for that task
|
|
471
508
|
const claims = claimsOf(all);
|
|
@@ -525,6 +562,10 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
525
562
|
} else {
|
|
526
563
|
console.log(`\n(all chunks checked — ready to ship or archive)`);
|
|
527
564
|
}
|
|
565
|
+
// Whether the last ticked chunk actually closed (chunk 9) — one line,
|
|
566
|
+
// silent when its sha verifies.
|
|
567
|
+
const hint = closureHint(planChecked);
|
|
568
|
+
if (hint) console.log(hint);
|
|
528
569
|
} else if (arg) {
|
|
529
570
|
console.error(`no id "${arg}" in the log`);
|
|
530
571
|
process.exit(1);
|
package/src/mem/index.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// Moved from templates/mem/mem.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
|
|
4
4
|
// Now an export function called by fapony.ts, not a standalone script.
|
|
5
5
|
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { levenshtein } from "../commands.js";
|
|
6
9
|
import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
|
|
7
10
|
import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
|
|
8
11
|
import { cmdRotate } from "./commands/rotate.js";
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
cmdRelease,
|
|
16
19
|
cmdSynced,
|
|
17
20
|
} from "./commands/write.js";
|
|
21
|
+
import { doneDir, planDir } from "./store.js";
|
|
18
22
|
|
|
19
23
|
const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
|
|
20
24
|
|
|
@@ -86,6 +90,42 @@ reads the Stop-hook payload on stdin — installed by fapony, not run by hand
|
|
|
86
90
|
example: (installed hook only)`,
|
|
87
91
|
};
|
|
88
92
|
|
|
93
|
+
// Subcommand names for top-level did-you-mean (src/commands.ts) — derived
|
|
94
|
+
// from HELP keys so the list cannot drift from the dispatch below.
|
|
95
|
+
export const MEM_SUBCOMMANDS: string[] = Object.keys(HELP);
|
|
96
|
+
|
|
97
|
+
// Chunk 7 (PLAN-seed-and-surface, bug muc85pml): `fapony mem now` used to fall
|
|
98
|
+
// through to kickoff and print an overview ("N entries") instead of an error —
|
|
99
|
+
// a typo that looks like success. Three rules, in order:
|
|
100
|
+
// 1. a known subcommand → dispatch (unchanged)
|
|
101
|
+
// 2. looks like a plan path (.md suffix, a slash, or a file with that name
|
|
102
|
+
// in planDir/doneDir) → kickoff — this is `fapony mem PLAN-x.md`, which
|
|
103
|
+
// must keep working without the `kickoff` word
|
|
104
|
+
// 3. anything else → error, exit 1 (a typo is never a kickoff)
|
|
105
|
+
//
|
|
106
|
+
// `exists` is injectable so tests hit this directly without a store on disk —
|
|
107
|
+
// production passes nothing and reads planDir/doneDir live.
|
|
108
|
+
export function classifyMemArg(
|
|
109
|
+
arg: string,
|
|
110
|
+
exists?: (name: string) => boolean,
|
|
111
|
+
): "subcommand" | "plan" | "unknown" {
|
|
112
|
+
// hasOwn, not `in` or indexing — prototype props ("toString") are not
|
|
113
|
+
// subcommands.
|
|
114
|
+
if (Object.hasOwn(HELP, arg)) return "subcommand";
|
|
115
|
+
if (arg.endsWith(".md") || arg.includes("/")) return "plan";
|
|
116
|
+
const has =
|
|
117
|
+
exists ??
|
|
118
|
+
((name: string): boolean => {
|
|
119
|
+
const base = basename(name);
|
|
120
|
+
const candidates = [name, base, `${name}.md`, `${base}.md`];
|
|
121
|
+
return candidates.some(
|
|
122
|
+
(c) => existsSync(join(planDir, c)) || existsSync(join(doneDir, c)),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
if (has(arg)) return "plan";
|
|
126
|
+
return "unknown";
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
90
130
|
const [cmd, ...rest] = a;
|
|
91
131
|
|
|
@@ -128,8 +168,27 @@ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
|
128
168
|
cmdPlanCheck(rest);
|
|
129
169
|
} else if (cmd === "rotate") {
|
|
130
170
|
cmdRotate(rest);
|
|
131
|
-
} else {
|
|
171
|
+
} else if (cmd === undefined) {
|
|
132
172
|
// bare `fapony mem` → kickoff (ranked session overview)
|
|
133
173
|
cmdKickoff(rest);
|
|
174
|
+
} else if (classifyMemArg(cmd) === "plan") {
|
|
175
|
+
// `fapony mem PLAN-x.md` — the plan arg rides along (the old else-branch
|
|
176
|
+
// dropped it and printed the no-arg overview instead).
|
|
177
|
+
cmdKickoff([cmd, ...rest]);
|
|
178
|
+
} else {
|
|
179
|
+
console.error(`fapony mem: unknown subcommand "${cmd}"`);
|
|
180
|
+
const scored = MEM_SUBCOMMANDS.map((s) => ({
|
|
181
|
+
s,
|
|
182
|
+
d: levenshtein(cmd, s),
|
|
183
|
+
}))
|
|
184
|
+
.filter((x) => x.d <= 2)
|
|
185
|
+
.sort((x, y) => x.d - y.d || (x.s < y.s ? -1 : 1))
|
|
186
|
+
.slice(0, 3)
|
|
187
|
+
.map((x) => `"fapony mem ${x.s}"`);
|
|
188
|
+
if (scored.length > 0) {
|
|
189
|
+
console.error(`did you mean ${scored.join(" or ")}?`);
|
|
190
|
+
}
|
|
191
|
+
console.error(`subcommands: ${MEM_SUBCOMMANDS.join(" ")}`);
|
|
192
|
+
process.exit(1);
|
|
134
193
|
}
|
|
135
194
|
}
|
package/src/memory.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function claimMemory(
|
|
|
74
74
|
config: Config,
|
|
75
75
|
worktree: string,
|
|
76
76
|
memId: string,
|
|
77
|
+
timeoutMs = 15_000,
|
|
77
78
|
): boolean {
|
|
78
79
|
const mem = resolveMemoryConfig(config, worktree);
|
|
79
80
|
if (!mem) return false;
|
|
@@ -83,7 +84,7 @@ export function claimMemory(
|
|
|
83
84
|
execSync(cmd.join(" "), {
|
|
84
85
|
cwd: worktree,
|
|
85
86
|
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
-
timeout:
|
|
87
|
+
timeout: timeoutMs,
|
|
87
88
|
});
|
|
88
89
|
return true;
|
|
89
90
|
} catch {
|
package/src/seed/plan-seed.ts
CHANGED
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
} from "node:fs";
|
|
36
36
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
37
37
|
import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "../analyze.js";
|
|
38
|
-
import { computeModelFit } from "../context/projectHealth.js";
|
|
39
38
|
import {
|
|
40
39
|
CONFIG_FILENAME,
|
|
41
40
|
type Config,
|
|
@@ -46,7 +45,6 @@ import {
|
|
|
46
45
|
} from "../core/config.js";
|
|
47
46
|
import { extractExports } from "../map.js";
|
|
48
47
|
import { readRecentMemDecisions } from "../memory.js";
|
|
49
|
-
import { getStatsData } from "../stats/data.js";
|
|
50
48
|
import { capLines, execGit, SIG_MAX } from "./primitives.js";
|
|
51
49
|
|
|
52
50
|
// One chunk = one module's signatures — past ~40 lines a module is its own
|
|
@@ -60,6 +58,14 @@ const SCOPE_WARN_FILES = 300;
|
|
|
60
58
|
// Shipped plans/specs that already touched this scope. Capped low on purpose:
|
|
61
59
|
// this is a "go read that first" pointer, not a bibliography.
|
|
62
60
|
const MAX_PRIOR_ART = 5;
|
|
61
|
+
// Chunk 4 (PLAN-seed-and-surface): the PLAN names what is already in scope —
|
|
62
|
+
// one line per scope file with its export names. SPEC-only seeds never gave
|
|
63
|
+
// PLAN-only readers this pointer, so agents re-derived what export-lines.ts
|
|
64
|
+
// already knew. Capped low: a pointer, not a signature dump.
|
|
65
|
+
const MAX_EXISTING_IN_SCOPE = 15;
|
|
66
|
+
// The PLAN ≤ ~60 contract (§3.1) predates this block — the block yields to it,
|
|
67
|
+
// never grows it. Effective block cap = min(block cap, remaining budget).
|
|
68
|
+
const MAX_PLAN_LINES = 60;
|
|
63
69
|
// Anchor-safe slug: lowercase, non-alphanumerics → dash.
|
|
64
70
|
const slug = (s: string): string =>
|
|
65
71
|
s
|
|
@@ -149,7 +155,84 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
|
|
|
149
155
|
return shown.join("\n");
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
//
|
|
158
|
+
// Chunk 5 (PLAN-seed-and-surface): stdout ends with the plans that already
|
|
159
|
+
// exist — the seed that lands next to a shipped decision without knowing it
|
|
160
|
+
// is the expensive mistake (§8 prior art guards the file, this guards the
|
|
161
|
+
// glance). Active plans first (the ones a new seed must not duplicate),
|
|
162
|
+
// then shipped; capped like every other list here.
|
|
163
|
+
const MAX_PLAN_LIST = 10;
|
|
164
|
+
|
|
165
|
+
function listExistingPlans(
|
|
166
|
+
cwd: string,
|
|
167
|
+
config: Config,
|
|
168
|
+
exclude: string,
|
|
169
|
+
): string[] {
|
|
170
|
+
const items: string[] = [];
|
|
171
|
+
for (const [dir, where] of [
|
|
172
|
+
[planDir(), "plan"],
|
|
173
|
+
[doneDir(config), "done"],
|
|
174
|
+
] as const) {
|
|
175
|
+
let names: string[];
|
|
176
|
+
try {
|
|
177
|
+
names = readdirSync(join(cwd, dir))
|
|
178
|
+
.filter((n) => n.endsWith(".md"))
|
|
179
|
+
.sort();
|
|
180
|
+
} catch {
|
|
181
|
+
continue; // dir missing — nothing seeded yet
|
|
182
|
+
}
|
|
183
|
+
for (const n of names) {
|
|
184
|
+
if (n === exclude) continue; // the file just written, not "existing"
|
|
185
|
+
items.push(`- ${n} (${where})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (items.length === 0) return ["- (none yet)"];
|
|
189
|
+
return capLines(items, MAX_PLAN_LIST, "plans");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Context (fapony): mem decisions + existing in scope ---
|
|
193
|
+
//
|
|
194
|
+
// No ledger-ranking line here (PLAN-seed-and-surface chunk 6): the ledger is
|
|
195
|
+
// frozen and Positioning rule 2 forbids cross-model ranking claims, so a
|
|
196
|
+
// seeded pointer at it teaches the reader to cite what cannot be cited.
|
|
197
|
+
// computeModelFit() itself stays — `fapony stats` reads it.
|
|
198
|
+
|
|
199
|
+
// One line per scope file naming its exports — `src/debt/scan.ts —
|
|
200
|
+
// scanDebt() · DebtHit`. Files with no exports (or unreadable) are skipped:
|
|
201
|
+
// a pointer lists what is there, not what is not. Sorted for determinism.
|
|
202
|
+
function renderExistingInScope(
|
|
203
|
+
roots: string[],
|
|
204
|
+
cwd: string,
|
|
205
|
+
scoped: boolean,
|
|
206
|
+
cap: number,
|
|
207
|
+
): string[] {
|
|
208
|
+
// No --scope means every file in the repo matches — a list of everything
|
|
209
|
+
// points at nothing (§8 prior art goes quiet for the same reason).
|
|
210
|
+
if (!scoped)
|
|
211
|
+
return ["- _(no --scope — re-seed with --scope <dir> to list exports)_"];
|
|
212
|
+
const abs: string[] = [];
|
|
213
|
+
for (const r of roots) for (const f of scopeSourceFiles(r)) abs.push(f);
|
|
214
|
+
const rels = [...new Set(abs.map((f) => relative(cwd, f) || "."))].sort();
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
for (const rel of rels) {
|
|
217
|
+
let source: string;
|
|
218
|
+
try {
|
|
219
|
+
source = readFileSync(join(cwd, rel), "utf-8");
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const scan = extractExports(source);
|
|
224
|
+
if (scan.error || scan.symbols.length === 0) continue;
|
|
225
|
+
// Re-export-only files scan as one `*` per line — dedupe to a single `*`.
|
|
226
|
+
const names = [
|
|
227
|
+
...new Set(
|
|
228
|
+
scan.symbols.map((s) => (s.kind === "fn" ? `${s.name}()` : s.name)),
|
|
229
|
+
),
|
|
230
|
+
];
|
|
231
|
+
lines.push(`- ${rel} — ${names.join(" · ")}`);
|
|
232
|
+
}
|
|
233
|
+
if (lines.length === 0) return ["- _(no exports in scope)_"];
|
|
234
|
+
return capLines(lines, cap, "files in scope (narrow with --scope <path>)");
|
|
235
|
+
}
|
|
153
236
|
|
|
154
237
|
function renderContextFapony(worktree: string): string {
|
|
155
238
|
const lines: string[] = [];
|
|
@@ -164,17 +247,6 @@ function renderContextFapony(worktree: string): string {
|
|
|
164
247
|
.join(" · ")}`
|
|
165
248
|
: "- Decisions on record (mem): _(none — no mem log or empty)_",
|
|
166
249
|
);
|
|
167
|
-
const fits = computeModelFit(getStatsData().byRegime, worktree);
|
|
168
|
-
lines.push(
|
|
169
|
-
fits.length > 0
|
|
170
|
-
? `- Model fit (ledger, min N=5): ${fits
|
|
171
|
-
.map(
|
|
172
|
-
(f) =>
|
|
173
|
-
`${f.regime} → ${f.model} (N=${f.gates}, fail ${(f.failRate * 100).toFixed(0)}%)`,
|
|
174
|
-
)
|
|
175
|
-
.join(" · ")}`
|
|
176
|
-
: "- Model fit: _(not enough graded history yet)_",
|
|
177
|
-
);
|
|
178
250
|
return lines.join("\n");
|
|
179
251
|
}
|
|
180
252
|
|
|
@@ -184,6 +256,7 @@ function planTemplate(
|
|
|
184
256
|
name: string,
|
|
185
257
|
priorArt: string,
|
|
186
258
|
contextFapony: string,
|
|
259
|
+
existingScope: string[],
|
|
187
260
|
specLink: string | null,
|
|
188
261
|
planRel: string,
|
|
189
262
|
): string {
|
|
@@ -204,6 +277,8 @@ status: active
|
|
|
204
277
|
|
|
205
278
|
## Context (fapony)
|
|
206
279
|
${contextFapony}
|
|
280
|
+
### Existing in scope
|
|
281
|
+
${existingScope.join("\n")}
|
|
207
282
|
|
|
208
283
|
## 1. Goal (why)
|
|
209
284
|
_(agent fills in)_
|
|
@@ -221,19 +296,11 @@ _(agent fills in)_
|
|
|
221
296
|
_(agent fills in)_
|
|
222
297
|
|
|
223
298
|
## 6. Steps (what in which order)
|
|
224
|
-
One step = one chunk = one session: finish it, close it, **stop** — starting the
|
|
225
|
-
next step in the same session is what rule 9 forbids.
|
|
299
|
+
One step = one chunk = one session: finish it, close it, **stop** — starting the next step in the same session is what rule 9 forbids.
|
|
226
300
|
|
|
227
301
|
1. _(agent fills in — each step must be verifiable)_
|
|
228
302
|
|
|
229
|
-
**Closing a step:** tick
|
|
230
|
-
files only · then hand off:
|
|
231
|
-
|
|
232
|
-
\`\`\`bash
|
|
233
|
-
fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}
|
|
234
|
-
\`\`\`
|
|
235
|
-
|
|
236
|
-
Next session opens with \`kickoff ${planRel}\` (or \`kickoff ${basename(planRel)}\` — kickoff resolves by filename too, so no need to retype the path).
|
|
303
|
+
**Closing a step:** tick TL;DR with sha · \`git commit\` files only · \`fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}\` · next opens with \`kickoff ${planRel}\` (or \`kickoff PLAN-${name}.md\` — kickoff resolves by filename too).
|
|
237
304
|
|
|
238
305
|
## 7. Examples
|
|
239
306
|
${
|
|
@@ -566,6 +633,14 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
566
633
|
|
|
567
634
|
const priorArt = renderPriorArt(cwd, config, roots);
|
|
568
635
|
const contextFapony = renderContextFapony(worktree);
|
|
636
|
+
const scoped = requested.length > 0;
|
|
637
|
+
// First pass at the block cap — the total-cap check below may shrink it.
|
|
638
|
+
let existingScope = renderExistingInScope(
|
|
639
|
+
roots,
|
|
640
|
+
cwd,
|
|
641
|
+
scoped,
|
|
642
|
+
MAX_EXISTING_IN_SCOPE,
|
|
643
|
+
);
|
|
569
644
|
|
|
570
645
|
let specLink: string | null = null;
|
|
571
646
|
if (withSpec) {
|
|
@@ -593,15 +668,30 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
593
668
|
}
|
|
594
669
|
|
|
595
670
|
mkdirSync(planDirAbs, { recursive: true });
|
|
596
|
-
|
|
597
|
-
planPath,
|
|
671
|
+
const buildPlan = (existing: string[]): string =>
|
|
598
672
|
planTemplate(
|
|
599
673
|
name,
|
|
600
674
|
priorArt,
|
|
601
675
|
contextFapony,
|
|
676
|
+
existing,
|
|
602
677
|
specLink,
|
|
603
678
|
`${planDir()}/PLAN-${name}.md`,
|
|
604
|
-
)
|
|
605
|
-
);
|
|
679
|
+
);
|
|
680
|
+
let planBody = buildPlan(existingScope);
|
|
681
|
+
// The ≤ ~60 contract predates the §4 block — shrink the block (never the
|
|
682
|
+
// judgment sections) until the file fits. Each item is one line, so cutting
|
|
683
|
+
// `over` items fixes exactly; the re-render recounts the cut honestly.
|
|
684
|
+
const planLines = (b: string): number =>
|
|
685
|
+
b.replace(/\n$/, "").split("\n").length;
|
|
686
|
+
const over = planLines(planBody) - MAX_PLAN_LINES;
|
|
687
|
+
if (over > 0) {
|
|
688
|
+
const budget = Math.max(existingScope.length - over, 1);
|
|
689
|
+
existingScope = renderExistingInScope(roots, cwd, scoped, budget);
|
|
690
|
+
planBody = buildPlan(existingScope);
|
|
691
|
+
}
|
|
692
|
+
writeFileSync(planPath, planBody);
|
|
606
693
|
console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
|
|
694
|
+
console.log("Existing plans:");
|
|
695
|
+
for (const l of listExistingPlans(cwd, config, `PLAN-${name}.md`))
|
|
696
|
+
console.log(l);
|
|
607
697
|
}
|
package/src/update.ts
CHANGED
|
@@ -206,7 +206,7 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
|
206
206
|
│ Update complete! │
|
|
207
207
|
│ │
|
|
208
208
|
│ Version: ${newVersion.padEnd(31)}│
|
|
209
|
-
│ Run "
|
|
209
|
+
│ Run "bun run test" to verify. │
|
|
210
210
|
└──────────────────────────────────────────┘
|
|
211
211
|
`);
|
|
212
212
|
}
|
package/src/test.ts
DELETED