fapony 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -6
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +1 -2
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -139
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -410
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -252
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -375
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -738
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -273
- package/src/mem/engine.ts +0 -315
- package/src/mem/index.ts +0 -201
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -160
- package/src/mem/store.ts +0 -356
package/src/mem/commands/read.ts
DELETED
|
@@ -1,738 +0,0 @@
|
|
|
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 { parseSince } from "../../core/since.js";
|
|
6
|
-
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
7
|
-
import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
|
|
8
|
-
import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
9
|
-
import { claimsOf, openKeys, openRows, staleReport } from "../selectors.js";
|
|
10
|
-
import type { CloseRow, LogRow, WorkRow } from "../store.js";
|
|
11
|
-
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
12
|
-
import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
13
|
-
import { THRESHOLD } from "./rotate.js";
|
|
14
|
-
|
|
15
|
-
/** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
|
|
16
|
-
function getBranchDiffFiles(cwd: string): Set<string> {
|
|
17
|
-
try {
|
|
18
|
-
const p = Bun.spawnSync(["git", "diff", "--name-only", "dev...HEAD"], {
|
|
19
|
-
cwd,
|
|
20
|
-
stdout: "pipe",
|
|
21
|
-
stderr: "pipe",
|
|
22
|
-
});
|
|
23
|
-
if (p.exitCode !== 0) return new Set();
|
|
24
|
-
return new Set(p.stdout.toString().trim().split("\n").filter(Boolean));
|
|
25
|
-
} catch {
|
|
26
|
-
return new Set();
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const rotateLine = (n: number) =>
|
|
31
|
-
n >= THRESHOLD
|
|
32
|
-
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
33
|
-
: "";
|
|
34
|
-
|
|
35
|
-
const planSweepLine = () => {
|
|
36
|
-
const pending = shippedNotMoved();
|
|
37
|
-
return pending.length
|
|
38
|
-
? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
|
|
39
|
-
pending.map((n) => `- ${n}`).join("\n") +
|
|
40
|
-
`\n(move: ${planSweepCmd} <file.md> --apply)`
|
|
41
|
-
: "";
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// Row-list caps: no row-list section exceeds 10 (same budget as ## recent,
|
|
45
|
-
// which takes doneLines(all, 10)). Per-row text is cut at a word boundary —
|
|
46
|
-
// a kickoff line points at the row (mem find has the full text), it must not
|
|
47
|
-
// reprint it.
|
|
48
|
-
const BUGS_LIMIT = 10;
|
|
49
|
-
const BRANCH_LIMIT = 10;
|
|
50
|
-
const RECENT_OPEN_LIMIT = 10;
|
|
51
|
-
const RECENT_OPEN_TEXT = 160;
|
|
52
|
-
const OPEN_KEYS_LIMIT = 8;
|
|
53
|
-
|
|
54
|
-
// Important-index line (PLAN-mem-keys chunk 3): one line under the kickoff
|
|
55
|
-
// header listing keys that still have open rows — silent when none. Placed
|
|
56
|
-
// before every tier so the 4KB session-start cap can never drop it (only
|
|
57
|
-
// ## recent is cut first). Counts are open rows per key, not total rows.
|
|
58
|
-
const openKeysLine = (all: LogRow[]): string => {
|
|
59
|
-
const keys = openKeys(all);
|
|
60
|
-
if (!keys.length) return "";
|
|
61
|
-
const shown = keys.slice(0, OPEN_KEYS_LIMIT);
|
|
62
|
-
const body = shown.map(({ key, open }) => `${key}(${open})`).join(", ");
|
|
63
|
-
const more =
|
|
64
|
-
keys.length > OPEN_KEYS_LIMIT ? ` +${keys.length - OPEN_KEYS_LIMIT}` : "";
|
|
65
|
-
return `open keys: ${body}${more} — ${memCmd} find --key <key>`;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
|
|
69
|
-
if (text.length <= max) return text;
|
|
70
|
-
const cut = text.slice(0, max);
|
|
71
|
-
const lastSpace = cut.lastIndexOf(" ");
|
|
72
|
-
return `${lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut}…`;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export const cmdDone = () => {
|
|
76
|
-
const all = rows();
|
|
77
|
-
for (const l of doneLines(all)) console.log(l);
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
export const cmdStale = () => {
|
|
81
|
-
for (const l of staleReport(rows())) console.log(l);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export const cmdFind = (a: string[]) => {
|
|
85
|
-
// mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]
|
|
86
|
-
// Query logic lives in the shared engine (../engine.ts) — this wrapper owns
|
|
87
|
-
// only argv parsing + the single-line print. MCP memFind calls the same
|
|
88
|
-
// engine with no kind default (contract); CLI keeps its legacy default of
|
|
89
|
-
// hiding bookkeeping rows unless --kind names them (PLAN-unify-mem-engine).
|
|
90
|
-
const flagVal = (flag: string, i: number): string | undefined => {
|
|
91
|
-
const t = a[i];
|
|
92
|
-
const eq = `${flag}=`;
|
|
93
|
-
if (t.startsWith(eq)) return t.slice(eq.length);
|
|
94
|
-
return a[i + 1];
|
|
95
|
-
};
|
|
96
|
-
const consumed = (flag: string, i: number): number => {
|
|
97
|
-
if (a[i].startsWith(`${flag}=`)) return 1;
|
|
98
|
-
return 2;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
// Accumulators, not reassignments: a repeated list flag must grow the set,
|
|
102
|
-
// never last-win (PLAN-comma-x chunk 2). Exported as string[] | undefined
|
|
103
|
-
// after the loop so every downstream check reads the same shape.
|
|
104
|
-
const kindAcc: string[] = [];
|
|
105
|
-
const filesAcc: string[] = [];
|
|
106
|
-
let sinceRaw: string | undefined;
|
|
107
|
-
let limit: number | undefined;
|
|
108
|
-
let open = false;
|
|
109
|
-
let key: string | undefined;
|
|
110
|
-
const positional: string[] = [];
|
|
111
|
-
|
|
112
|
-
for (let i = 0; i < a.length; ) {
|
|
113
|
-
const t = a[i];
|
|
114
|
-
if (t === "--kind" || t.startsWith("--kind=")) {
|
|
115
|
-
const v = flagVal("--kind", i);
|
|
116
|
-
if (!v || v.startsWith("--")) {
|
|
117
|
-
console.error(
|
|
118
|
-
`--kind needs a value — usage: ${memCmd} find "<text>" --kind bug,decision`,
|
|
119
|
-
);
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
kindAcc.push(
|
|
123
|
-
...v
|
|
124
|
-
.split(",")
|
|
125
|
-
.map((s) => s.trim())
|
|
126
|
-
.filter(Boolean),
|
|
127
|
-
);
|
|
128
|
-
i += consumed("--kind", i);
|
|
129
|
-
} else if (t === "--files" || t.startsWith("--files=")) {
|
|
130
|
-
const v = flagVal("--files", i);
|
|
131
|
-
if (!v || v.startsWith("--")) {
|
|
132
|
-
console.error(
|
|
133
|
-
`--files needs a value — usage: ${memCmd} find --files path/to/file.ts[,more]`,
|
|
134
|
-
);
|
|
135
|
-
process.exit(1);
|
|
136
|
-
}
|
|
137
|
-
filesAcc.push(
|
|
138
|
-
...v
|
|
139
|
-
.split(",")
|
|
140
|
-
.map((s) => s.trim().replace(/^\.\//, ""))
|
|
141
|
-
.filter(Boolean),
|
|
142
|
-
);
|
|
143
|
-
i += consumed("--files", i);
|
|
144
|
-
} else if (t === "--key" || t.startsWith("--key=")) {
|
|
145
|
-
const v = flagVal("--key", i);
|
|
146
|
-
if (!v || v.startsWith("--")) {
|
|
147
|
-
console.error(
|
|
148
|
-
`--key needs a value — usage: ${memCmd} find --key fix-stop-dedupe`,
|
|
149
|
-
);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
// No KEY_RE validation on the read side — a wrong-pattern key never
|
|
153
|
-
// exists in the log, so the engine answers with knownKeys instead.
|
|
154
|
-
key = v;
|
|
155
|
-
i += consumed("--key", i);
|
|
156
|
-
} else if (t === "--since" || t.startsWith("--since=")) {
|
|
157
|
-
const v = flagVal("--since", i);
|
|
158
|
-
if (!v || v.startsWith("--")) {
|
|
159
|
-
console.error(`--since needs a value — use <N>d or YYYY-MM-DD`);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
}
|
|
162
|
-
sinceRaw = v;
|
|
163
|
-
i += consumed("--since", i);
|
|
164
|
-
} else if (t === "--limit" || t.startsWith("--limit=")) {
|
|
165
|
-
const v = flagVal("--limit", i);
|
|
166
|
-
const n = Number(v);
|
|
167
|
-
if (!v || v.startsWith("--") || !Number.isInteger(n) || n < 0) {
|
|
168
|
-
console.error(
|
|
169
|
-
`--limit needs a non-negative integer — got "${v ?? ""}"`,
|
|
170
|
-
);
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
limit = n;
|
|
174
|
-
i += consumed("--limit", i);
|
|
175
|
-
} else if (t === "--open") {
|
|
176
|
-
open = true;
|
|
177
|
-
i += 1;
|
|
178
|
-
} else {
|
|
179
|
-
positional.push(t);
|
|
180
|
-
i += 1;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const kind = kindAcc.length ? kindAcc : undefined;
|
|
185
|
-
const files = filesAcc.length ? filesAcc : undefined;
|
|
186
|
-
const q = positional.join(" ").trim();
|
|
187
|
-
if (
|
|
188
|
-
!q &&
|
|
189
|
-
!kind?.length &&
|
|
190
|
-
!files?.length &&
|
|
191
|
-
!key &&
|
|
192
|
-
!sinceRaw &&
|
|
193
|
-
limit === undefined &&
|
|
194
|
-
!open
|
|
195
|
-
) {
|
|
196
|
-
console.error(
|
|
197
|
-
`usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]`,
|
|
198
|
-
);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
let sinceIso: string | undefined;
|
|
203
|
-
if (sinceRaw) {
|
|
204
|
-
try {
|
|
205
|
-
sinceIso = parseSince(sinceRaw).iso;
|
|
206
|
-
} catch (e) {
|
|
207
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// allRows, not rows: find is recall — rotated history counts
|
|
213
|
-
const result = engineFind(allRows(), {
|
|
214
|
-
text: q || undefined,
|
|
215
|
-
files,
|
|
216
|
-
kind,
|
|
217
|
-
excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
|
|
218
|
-
sinceIso,
|
|
219
|
-
key,
|
|
220
|
-
limit,
|
|
221
|
-
open: open || undefined,
|
|
222
|
-
});
|
|
223
|
-
// Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
|
|
224
|
-
const hits = [...result.rows].reverse();
|
|
225
|
-
for (const r of hits) {
|
|
226
|
-
if ("id" in r) {
|
|
227
|
-
// #key only when present — rows without one keep the old shape
|
|
228
|
-
const keyTag = r.key ? ` #${r.key}` : "";
|
|
229
|
-
console.log(
|
|
230
|
-
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind}${keyTag} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
231
|
-
);
|
|
232
|
-
} else {
|
|
233
|
-
const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
|
|
234
|
-
const body = ("text" in r && r.text) || ("spec" in r && r.spec) || "";
|
|
235
|
-
console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (!hits.length) {
|
|
239
|
-
// A wrong key answers with the real ones (SPEC fail example) — a silent
|
|
240
|
-
// empty would read as "this problem never happened" (rule 9).
|
|
241
|
-
if (key && result.knownKeys) {
|
|
242
|
-
console.log(
|
|
243
|
-
`no key ${key}; known keys: ${
|
|
244
|
-
result.knownKeys.length ? result.knownKeys.join(", ") : "(none)"
|
|
245
|
-
}`,
|
|
246
|
-
);
|
|
247
|
-
} else {
|
|
248
|
-
console.log("(no matches)");
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
/** Read a plan file and extract checked + unchecked items from the first ## section. */
|
|
254
|
-
const readPlanSectionItems = (
|
|
255
|
-
planPath: string,
|
|
256
|
-
): { checked: string[]; unchecked: string[] } => {
|
|
257
|
-
try {
|
|
258
|
-
const text = readFileSync(planPath, "utf8");
|
|
259
|
-
// Skip frontmatter
|
|
260
|
-
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
261
|
-
// Find the first ## section (TL;DR)
|
|
262
|
-
const start = body.search(/^##\s+/m);
|
|
263
|
-
if (start < 0) return { checked: [], unchecked: [] };
|
|
264
|
-
const rest = body.slice(start);
|
|
265
|
-
const next = rest.slice(3).search(/^##\s+/m);
|
|
266
|
-
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
267
|
-
// Extract checkboxes — ticked and unticked
|
|
268
|
-
const checked: string[] = [];
|
|
269
|
-
const unchecked: string[] = [];
|
|
270
|
-
for (const line of block.split("\n")) {
|
|
271
|
-
let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
|
|
272
|
-
if (m) {
|
|
273
|
-
unchecked.push(m[1].trim());
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
|
|
277
|
-
if (m) checked.push(m[1].trim());
|
|
278
|
-
}
|
|
279
|
-
return { checked, unchecked };
|
|
280
|
-
} catch {
|
|
281
|
-
return { checked: [], unchecked: [] };
|
|
282
|
-
}
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
// Chunk 9 (PLAN-seed-and-surface): one line under the next-chunk display
|
|
286
|
-
// saying whether the last ticked chunk actually closed. Verified shas stay
|
|
287
|
-
// silent; only the newest ticked chunk is ever mentioned — one line per
|
|
288
|
-
// kickoff, never a list (else vela's 64/72 sha-less chunks become noise).
|
|
289
|
-
const closureHint = (checked: string[]): string | null => {
|
|
290
|
-
const last = checked[checked.length - 1];
|
|
291
|
-
if (!last) return null;
|
|
292
|
-
const { missing, diverged, cited } = checkTickedLine(last, root);
|
|
293
|
-
const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
|
|
294
|
-
if (missing.length)
|
|
295
|
-
return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
|
|
296
|
-
if (diverged.length)
|
|
297
|
-
return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
|
|
298
|
-
if (!cited)
|
|
299
|
-
return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
|
|
300
|
-
return null;
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
/** Check if a plan has `priority: high` in its frontmatter. */
|
|
304
|
-
const hasHighPriority = (planPath: string): boolean => {
|
|
305
|
-
try {
|
|
306
|
-
const head = readFileSync(planPath, "utf8").slice(0, 1024);
|
|
307
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
|
|
308
|
-
if (!m) return false;
|
|
309
|
-
return /^\s*priority\s*:\s*high\s*$/m.test(m[1]);
|
|
310
|
-
} catch {
|
|
311
|
-
return false;
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
/** Read the title from a plan file (first # heading). */
|
|
316
|
-
const readPlanTitle = (planPath: string): string => {
|
|
317
|
-
try {
|
|
318
|
-
const text = readFileSync(planPath, "utf8");
|
|
319
|
-
const m = /^#\s+(.+)$/m.exec(text);
|
|
320
|
-
return m ? m[1].trim() : "";
|
|
321
|
-
} catch {
|
|
322
|
-
return "";
|
|
323
|
-
}
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
const normSpec = (s: string) => s.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
327
|
-
|
|
328
|
-
// kickoff's spec arg used to be compared to r.spec as a literal string, so
|
|
329
|
-
// `.fapony/plan/PLAN-x.md` had to be retyped byte-for-byte — and a typo printed
|
|
330
|
-
// "(no entries)" with nothing to notice. Resolve against the spec paths the log
|
|
331
|
-
// actually holds: exact path first, then by filename (with or without .md), then by
|
|
332
|
-
// any path suffix. Returns all candidates so the caller can tell unique from ambiguous.
|
|
333
|
-
const resolveSpec = (target: string, specs: string[]): string[] => {
|
|
334
|
-
const t = normSpec(target);
|
|
335
|
-
const all = [...new Set(specs)];
|
|
336
|
-
const exact = all.filter((s) => normSpec(s) === t);
|
|
337
|
-
if (exact.length) return exact;
|
|
338
|
-
const base = basename(t);
|
|
339
|
-
const withMd = base.endsWith(".md") ? base : `${base}.md`;
|
|
340
|
-
const byName = all.filter((s) => {
|
|
341
|
-
const b = basename(normSpec(s));
|
|
342
|
-
return b === base || b === withMd;
|
|
343
|
-
});
|
|
344
|
-
if (byName.length) return byName;
|
|
345
|
-
return all.filter((s) => normSpec(s).endsWith(`/${t}`));
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
export const cmdKickoff = (a: string[]) => {
|
|
349
|
-
// mem kickoff [id|spec.md] [--pick <n>]
|
|
350
|
-
// the spec may be the full path or just its filename
|
|
351
|
-
const all = rows();
|
|
352
|
-
|
|
353
|
-
// Parse --pick flag first
|
|
354
|
-
let pickIdx: number | null = null;
|
|
355
|
-
const pickFlagIdx = a.indexOf("--pick");
|
|
356
|
-
if (pickFlagIdx !== -1) {
|
|
357
|
-
const val = a[pickFlagIdx + 1];
|
|
358
|
-
pickIdx = parseInt(val ?? "", 10);
|
|
359
|
-
if (Number.isNaN(pickIdx) || pickIdx < 1) {
|
|
360
|
-
console.error("--pick requires a positive number");
|
|
361
|
-
process.exit(1);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// Filter out --pick and its value to get the main arguments
|
|
365
|
-
const mainArgs =
|
|
366
|
-
pickFlagIdx !== -1
|
|
367
|
-
? a.filter((_, i) => i !== pickFlagIdx && i !== pickFlagIdx + 1)
|
|
368
|
-
: a;
|
|
369
|
-
const arg = mainArgs[0] ?? "";
|
|
370
|
-
|
|
371
|
-
// --- resolve spec/id for the main output ---
|
|
372
|
-
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
373
|
-
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
374
|
-
const specNames = [
|
|
375
|
-
...new Set(
|
|
376
|
-
all.flatMap((r) => ("spec" in r && r.spec ? [r.spec as string] : [])),
|
|
377
|
-
),
|
|
378
|
-
].sort();
|
|
379
|
-
const target = byId.get(arg);
|
|
380
|
-
const matches = resolveSpec(arg, specNames);
|
|
381
|
-
let resolvedSpec = matches.find((s) => normSpec(s) === normSpec(arg));
|
|
382
|
-
|
|
383
|
-
// If no arg or arg didn't match an id/spec, check if it's a plan file
|
|
384
|
-
let planFile: string | null = null;
|
|
385
|
-
let planCheckboxes: string[] = [];
|
|
386
|
-
let planChecked: string[] = [];
|
|
387
|
-
|
|
388
|
-
if (arg && !resolvedSpec && !target) {
|
|
389
|
-
if (matches.length === 1) {
|
|
390
|
-
resolvedSpec = matches[0];
|
|
391
|
-
} else if (matches.length > 1) {
|
|
392
|
-
console.error(
|
|
393
|
-
`"${arg}" could mean ${matches.length} specs — retype one in full:`,
|
|
394
|
-
);
|
|
395
|
-
for (const m of matches) console.error(` ${m}`);
|
|
396
|
-
process.exit(1);
|
|
397
|
-
} else if (arg.endsWith(".md")) {
|
|
398
|
-
// Try to find it as a plan file
|
|
399
|
-
const dir = planDir;
|
|
400
|
-
const candidates = [join(dir, basename(arg)), join(dir, arg), arg];
|
|
401
|
-
for (const c of candidates) {
|
|
402
|
-
if (existsSync(c)) {
|
|
403
|
-
planFile = c;
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (!planFile) {
|
|
408
|
-
console.error(`no entries for spec "${arg}"`);
|
|
409
|
-
if (specNames.length) {
|
|
410
|
-
console.error(`specs in the log:`);
|
|
411
|
-
for (const s of specNames) console.error(` ${s}`);
|
|
412
|
-
}
|
|
413
|
-
process.exit(1);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Read plan file if we found one (or if a .md arg matched a spec)
|
|
419
|
-
if (planFile) {
|
|
420
|
-
const items = readPlanSectionItems(planFile);
|
|
421
|
-
planCheckboxes = items.unchecked;
|
|
422
|
-
planChecked = items.checked;
|
|
423
|
-
} else if (arg && resolvedSpec && arg.endsWith(".md")) {
|
|
424
|
-
// The spec resolved from the log might be a plan file — try to read it
|
|
425
|
-
const dir = planDir;
|
|
426
|
-
const byName = join(dir, basename(arg));
|
|
427
|
-
if (existsSync(byName)) {
|
|
428
|
-
planFile = byName;
|
|
429
|
-
const items = readPlanSectionItems(planFile);
|
|
430
|
-
planCheckboxes = items.unchecked;
|
|
431
|
-
planChecked = items.checked;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// --- suggestions (generated early so the no-args path can print next up
|
|
436
|
-
// before the recency tier, keeping it inside the 4KB cap) ---
|
|
437
|
-
|
|
438
|
-
type Suggestion = { text: string; run?: string; manual?: boolean };
|
|
439
|
-
const suggestions: Suggestion[] = [];
|
|
440
|
-
const GROUP_CAP = 3;
|
|
441
|
-
|
|
442
|
-
const truncate = (s: string, n = 80) =>
|
|
443
|
-
s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
|
444
|
-
|
|
445
|
-
// group 1 — plans: priority: high first, then the first unchecked chunk
|
|
446
|
-
const planLines: Suggestion[] = [];
|
|
447
|
-
if (existsSync(planDir)) {
|
|
448
|
-
const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
|
|
449
|
-
for (const file of files) {
|
|
450
|
-
if (planLines.length >= GROUP_CAP) break;
|
|
451
|
-
const fullPath = join(planDir, file);
|
|
452
|
-
if (planFile && fullPath === planFile) continue;
|
|
453
|
-
if (hasHighPriority(fullPath)) {
|
|
454
|
-
const title = readPlanTitle(fullPath);
|
|
455
|
-
planLines.push({
|
|
456
|
-
text: `${title || file} (${file})`,
|
|
457
|
-
run: `fapony mem kickoff ${file}`,
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
if (planLines.length < GROUP_CAP && planCheckboxes.length) {
|
|
463
|
-
const planName = planFile ? basename(planFile) : "plan";
|
|
464
|
-
planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
|
|
465
|
-
}
|
|
466
|
-
suggestions.push(...planLines);
|
|
467
|
-
|
|
468
|
-
// group 2 — open bugs, oldest first
|
|
469
|
-
const suggClaims = claimsOf(all);
|
|
470
|
-
const openBugs = openRows(all)
|
|
471
|
-
.filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
|
|
472
|
-
.slice(0, GROUP_CAP);
|
|
473
|
-
for (const b of openBugs) {
|
|
474
|
-
suggestions.push({
|
|
475
|
-
text: `bug #${b.id} — ${truncate(b.text)}`,
|
|
476
|
-
run: `fapony mem close ${b.id} "<msg>"`,
|
|
477
|
-
manual: true,
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// group 3 — the files of the last 3 rows that carried files[]
|
|
482
|
-
const recentFiles = all
|
|
483
|
-
.filter(
|
|
484
|
-
(r): r is WorkRow =>
|
|
485
|
-
"id" in r && "files" in r && !!(r as WorkRow).files?.length,
|
|
486
|
-
)
|
|
487
|
-
.slice(-3)
|
|
488
|
-
.flatMap((r) => r.files ?? []);
|
|
489
|
-
const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
|
|
490
|
-
if (uniqueRecent.length) {
|
|
491
|
-
suggestions.push({
|
|
492
|
-
text: `Recently touched: ${uniqueRecent.join(", ")}`,
|
|
493
|
-
run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// group 4 — the lint baseline
|
|
498
|
-
try {
|
|
499
|
-
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
500
|
-
suggestions.push({
|
|
501
|
-
text: "lint baseline not captured yet — pre-existing red will count as yours",
|
|
502
|
-
run: "fapony lint-baseline --capture",
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
} catch {
|
|
506
|
-
// state dir unreadable — the baseline line is a convenience, never a gate
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Print ## next up and handle --pick. */
|
|
510
|
-
const printNextUp = () => {
|
|
511
|
-
if (!suggestions.length) return;
|
|
512
|
-
console.log(`\n## next up`);
|
|
513
|
-
for (let i = 0; i < suggestions.length; i++) {
|
|
514
|
-
console.log(` [${i + 1}] ${suggestions[i].text}`);
|
|
515
|
-
if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
|
|
516
|
-
}
|
|
517
|
-
if (pickIdx !== null) {
|
|
518
|
-
if (pickIdx > suggestions.length) {
|
|
519
|
-
console.error(
|
|
520
|
-
`--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
|
|
521
|
-
);
|
|
522
|
-
process.exit(1);
|
|
523
|
-
}
|
|
524
|
-
const picked = suggestions[pickIdx - 1];
|
|
525
|
-
if (!picked.run) {
|
|
526
|
-
console.error(
|
|
527
|
-
`--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
|
|
528
|
-
);
|
|
529
|
-
process.exit(1);
|
|
530
|
-
}
|
|
531
|
-
if (picked.manual) {
|
|
532
|
-
console.error(
|
|
533
|
-
`--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
|
|
534
|
-
);
|
|
535
|
-
process.exit(1);
|
|
536
|
-
}
|
|
537
|
-
const cmd = picked.run;
|
|
538
|
-
console.log(`\n> running: ${cmd}`);
|
|
539
|
-
const { execSync } = require("node:child_process");
|
|
540
|
-
try {
|
|
541
|
-
execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
|
|
542
|
-
} catch {
|
|
543
|
-
process.exit(1);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
// --- main output ---
|
|
549
|
-
|
|
550
|
-
// Deferred output for the no-args path — recent closes, plan sweep, rotate
|
|
551
|
-
let kickoffExtras: (() => void) | null = null;
|
|
552
|
-
|
|
553
|
-
if (!arg && !planFile) {
|
|
554
|
-
// no args = ranked open rows + next up + recent closes
|
|
555
|
-
console.log(`# ${app} — ${all.length} entries`);
|
|
556
|
-
const keys = openKeysLine(all);
|
|
557
|
-
if (keys) console.log(keys);
|
|
558
|
-
|
|
559
|
-
const open = openRows(all);
|
|
560
|
-
const claims = claimsOf(all);
|
|
561
|
-
const diffFiles = getBranchDiffFiles(root);
|
|
562
|
-
|
|
563
|
-
// Tier 1: unclaimed bugs (always first, no matter how old) — capped: a
|
|
564
|
-
// backlog of unclosable bugs must not push the rest of the ranking out.
|
|
565
|
-
const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
|
|
566
|
-
if (bugs.length) {
|
|
567
|
-
console.log(`\n## bugs`);
|
|
568
|
-
for (const r of bugs.slice(0, BUGS_LIMIT)) console.log(fmtRow(r));
|
|
569
|
-
if (bugs.length > BUGS_LIMIT)
|
|
570
|
-
console.log(
|
|
571
|
-
`… +${bugs.length - BUGS_LIMIT} more unclosed bugs — \`fapony mem find <word>\` for the rest`,
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// next up BEFORE the branch/recency tiers so no row-list section can
|
|
576
|
-
// push it out — order is by actionability, never by "this tier is short".
|
|
577
|
-
printNextUp();
|
|
578
|
-
|
|
579
|
-
// Tier 2: rows whose files[] overlap with git diff --name-only dev...HEAD
|
|
580
|
-
// — capped like every other row-list section (≤10 + overflow, pointer
|
|
581
|
-
// lines via shortText): the branch diff is unbounded and mem clusters on
|
|
582
|
-
// files every real branch touches, so uncapped this reprints the log.
|
|
583
|
-
const bugIds = new Set(bugs.map((r) => r.id));
|
|
584
|
-
const diffMatched = open.filter((r) => {
|
|
585
|
-
if (bugIds.has(r.id)) return false;
|
|
586
|
-
if (diffFiles.size === 0) return false;
|
|
587
|
-
return (r.files ?? []).some((f) => diffFiles.has(f));
|
|
588
|
-
});
|
|
589
|
-
if (diffMatched.length) {
|
|
590
|
-
console.log(`\n## on this branch`);
|
|
591
|
-
for (const r of diffMatched.slice(0, BRANCH_LIMIT))
|
|
592
|
-
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
593
|
-
if (diffMatched.length > BRANCH_LIMIT)
|
|
594
|
-
console.log(
|
|
595
|
-
`… +${diffMatched.length - BRANCH_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Tier 3: rest by recency (newest first) — tie-breaker only, capped like
|
|
600
|
-
// ## recent (doneLines takes 10): decision/note can never close, so an
|
|
601
|
-
// uncapped tier reprints the whole log. Lines are pointers, not prose —
|
|
602
|
-
// cut at a word boundary and point at mem find for the rest.
|
|
603
|
-
const shown = new Set([...bugIds, ...diffMatched.map((r) => r.id)]);
|
|
604
|
-
const rest = open
|
|
605
|
-
.filter((r) => !shown.has(r.id))
|
|
606
|
-
.sort((a, b) => b.ts.localeCompare(a.ts));
|
|
607
|
-
if (rest.length) {
|
|
608
|
-
console.log(`\n## recent open`);
|
|
609
|
-
for (const r of rest.slice(0, RECENT_OPEN_LIMIT))
|
|
610
|
-
console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
|
|
611
|
-
if (rest.length > RECENT_OPEN_LIMIT)
|
|
612
|
-
console.log(
|
|
613
|
-
`… +${rest.length - RECENT_OPEN_LIMIT} more — \`fapony mem find <word>\` for the rest`,
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// recent closes, plan sweep, rotate — after next up
|
|
618
|
-
kickoffExtras = () => {
|
|
619
|
-
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
620
|
-
const sweep = planSweepLine();
|
|
621
|
-
if (sweep) console.log(sweep);
|
|
622
|
-
const rotate = rotateLine(all.length);
|
|
623
|
-
if (rotate) console.log(rotate);
|
|
624
|
-
};
|
|
625
|
-
} else if (resolvedSpec) {
|
|
626
|
-
// spec.md = a brief for that spec
|
|
627
|
-
const open = openRows(all);
|
|
628
|
-
const specRows = open.filter((r) => r.spec === resolvedSpec);
|
|
629
|
-
const specDecisions = all
|
|
630
|
-
.filter(
|
|
631
|
-
(r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
|
|
632
|
-
)
|
|
633
|
-
.slice(-5);
|
|
634
|
-
const specCloses = all
|
|
635
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
636
|
-
.filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
|
|
637
|
-
.slice(-3);
|
|
638
|
-
|
|
639
|
-
console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
|
|
640
|
-
if (specRows.length) {
|
|
641
|
-
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
642
|
-
}
|
|
643
|
-
if (specDecisions.length) {
|
|
644
|
-
console.log(
|
|
645
|
-
`\n## decisions\n` +
|
|
646
|
-
specDecisions
|
|
647
|
-
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
648
|
-
.join("\n"),
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
|
-
if (specCloses.length) {
|
|
652
|
-
console.log(
|
|
653
|
-
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
657
|
-
console.log("(no entries for this spec)");
|
|
658
|
-
}
|
|
659
|
-
// The spec is also a plan file on disk — say whether the last ticked
|
|
660
|
-
// chunk actually closed (chunk 9). Silent when it verifies.
|
|
661
|
-
if (planFile) {
|
|
662
|
-
const hint = closureHint(planChecked);
|
|
663
|
-
if (hint) console.log(`\n${hint}`);
|
|
664
|
-
}
|
|
665
|
-
} else if (target) {
|
|
666
|
-
// id = a brief for that task
|
|
667
|
-
const claims = claimsOf(all);
|
|
668
|
-
const claimInfo = claims.has(arg)
|
|
669
|
-
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
670
|
-
: "";
|
|
671
|
-
|
|
672
|
-
const spec = target.spec;
|
|
673
|
-
const open = spec
|
|
674
|
-
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
675
|
-
: [];
|
|
676
|
-
const specDecisions = spec
|
|
677
|
-
? all
|
|
678
|
-
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
679
|
-
.slice(-5)
|
|
680
|
-
: [];
|
|
681
|
-
const specNotes = spec
|
|
682
|
-
? all
|
|
683
|
-
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
684
|
-
.slice(-5)
|
|
685
|
-
: [];
|
|
686
|
-
const specCloses = spec
|
|
687
|
-
? all
|
|
688
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
689
|
-
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
690
|
-
.slice(-3)
|
|
691
|
-
: [];
|
|
692
|
-
|
|
693
|
-
console.log(
|
|
694
|
-
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
695
|
-
);
|
|
696
|
-
if (open.length) {
|
|
697
|
-
console.log(
|
|
698
|
-
`\n## open (same spec)\n` +
|
|
699
|
-
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
if (specDecisions.length || specNotes.length) {
|
|
703
|
-
console.log(`\n## decisions & notes (spec)\n`);
|
|
704
|
-
for (const r of [...specDecisions, ...specNotes]) {
|
|
705
|
-
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
if (specCloses.length) {
|
|
709
|
-
console.log(
|
|
710
|
-
`\n## closes (spec)\n` +
|
|
711
|
-
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
712
|
-
);
|
|
713
|
-
}
|
|
714
|
-
} else if (planFile) {
|
|
715
|
-
const title = readPlanTitle(planFile);
|
|
716
|
-
console.log(`# ${title || basename(planFile)} — plan`);
|
|
717
|
-
if (planCheckboxes.length) {
|
|
718
|
-
console.log(
|
|
719
|
-
`\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
|
|
720
|
-
);
|
|
721
|
-
} else {
|
|
722
|
-
console.log(`\n(all chunks checked — ready to ship or archive)`);
|
|
723
|
-
}
|
|
724
|
-
// Whether the last ticked chunk actually closed (chunk 9) — one line,
|
|
725
|
-
// silent when its sha verifies.
|
|
726
|
-
const hint = closureHint(planChecked);
|
|
727
|
-
if (hint) console.log(hint);
|
|
728
|
-
} else if (arg) {
|
|
729
|
-
console.error(`no id "${arg}" in the log`);
|
|
730
|
-
process.exit(1);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// For non-no-args paths, print next up at the end
|
|
734
|
-
if (arg || planFile) printNextUp();
|
|
735
|
-
|
|
736
|
-
// Deferred output from no-args path (recent closes, plan sweep, rotate)
|
|
737
|
-
kickoffExtras?.();
|
|
738
|
-
};
|