fapony 0.1.1 → 0.1.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 +22 -36
- 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/package.json +10 -8
- package/skill/plan-with-pony/SKILL.md +1 -1
- package/src/analyze.ts +9 -8
- package/src/conventions-seed.ts +10 -10
- package/src/db/index.ts +1 -1
- package/src/db/store.ts +4 -0
- package/src/debt.ts +26 -22
- package/src/digest/collect.ts +7 -7
- package/src/digest/html.ts +2 -2
- package/src/digest/text.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +119 -21
- package/src/init-mem.ts +3 -3
- package/src/install/opencode.ts +95 -8
- package/src/install/types.ts +1 -1
- package/src/install.ts +5 -1
- package/src/lint-baseline.ts +9 -9
- package/src/mcp/tools/mem.ts +1 -1
- package/src/mcp/tools/stats.ts +2 -2
- package/src/mcp/tools/usage.ts +3 -3
- package/src/mcp/tools/verdict.ts +29 -16
- package/src/mcp/transport.ts +1 -1
- package/src/plan-seed.ts +23 -21
- package/src/price/fetch.ts +19 -19
- package/src/price/resolve.ts +24 -24
- package/src/review-seed.ts +1 -1
- package/src/stats/data.ts +8 -7
- package/src/stats/format.ts +6 -5
- package/src/usage/render.ts +6 -5
- package/templates/PLAN.md +1 -1
- package/templates/mem/commands/plan.ts +44 -44
- package/templates/mem/commands/read.ts +5 -5
- package/templates/mem/commands/rotate.ts +6 -6
- package/templates/mem/commands/selftest.ts +14 -14
- package/templates/mem/commands/write.ts +13 -13
- package/templates/mem/mem.ts +5 -5
- package/templates/mem/selectors.ts +18 -18
- package/templates/mem/store.ts +50 -50
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// commands/plan.ts — plan-sweep:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// <file.md> =
|
|
5
|
-
// <file.md> --apply = git mv +
|
|
6
|
-
// +
|
|
1
|
+
// commands/plan.ts — plan-sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
|
|
2
|
+
// rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
|
|
3
|
+
// no arg = report only (safe, shows every kickoff/stale run)
|
|
4
|
+
// <file.md> = check a single file, is it ready to move
|
|
5
|
+
// <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from other files in plan/
|
|
6
|
+
// + warn about plain-text mentions (detect-only, no auto-fix)
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -21,13 +21,13 @@ const SHIPPED = /^>\s*✅/m;
|
|
|
21
21
|
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
22
22
|
const HELD = /^status:\s*(blocked|superseded)\b/m;
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// the ✅ shipped header is no longer on the first line — the current plan format starts with frontmatter
|
|
25
|
+
// then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
|
|
26
26
|
//
|
|
27
|
-
// frontmatter
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// frontmatter always beats the ✅ header: a plan that shipped some chunks and is waiting on externals (VPS, users,
|
|
28
|
+
// a decision) writes `status: blocked` = it is meant to stay in plan/, not forgotten to move
|
|
29
|
+
// if this is not checked, such plans show "shipped but never archived" forever, and people stop reading
|
|
30
|
+
// the whole list — the same symptom that kept done/ from ever moving in the first place
|
|
31
31
|
export const hasShippedHeader = (file: string): boolean => {
|
|
32
32
|
const head = readFileSync(file, "utf8").slice(0, 2048);
|
|
33
33
|
if (!SHIPPED.test(head)) return false;
|
|
@@ -49,10 +49,10 @@ const mdFiles = (dir: string): string[] =>
|
|
|
49
49
|
|
|
50
50
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
// resolve
|
|
54
|
-
//
|
|
55
|
-
// pass 1 only —
|
|
52
|
+
// the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
|
|
53
|
+
// resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
|
|
54
|
+
// if the target stayed in oldDir → ../target (both correct)
|
|
55
|
+
// pass 1 only — fix only [text](target) markdown links, do not touch plain text
|
|
56
56
|
export const rewriteMovedFileLinks = (
|
|
57
57
|
file: string,
|
|
58
58
|
oldDir: string,
|
|
@@ -76,8 +76,8 @@ export const rewriteMovedFileLinks = (
|
|
|
76
76
|
return n;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
// pass 1 only —
|
|
79
|
+
// other files whose links point at the old path (oldAbs) → repoint them at the new path (newAbs)
|
|
80
|
+
// pass 1 only — fix only [text](target) markdown links, do not touch plain text
|
|
81
81
|
export const rewriteMarkdownLinks = (
|
|
82
82
|
file: string,
|
|
83
83
|
oldAbs: string,
|
|
@@ -99,18 +99,18 @@ export const rewriteMarkdownLinks = (
|
|
|
99
99
|
return n;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
// regex:
|
|
104
|
-
//
|
|
102
|
+
// count plain-text mentions of the target filename in a file (detect-only, writes nothing)
|
|
103
|
+
// regex: not a markdown link [text](url) — catches both with/without the .md extension
|
|
104
|
+
// covers: prose, backtick code span, code fence — every context that is not a markdown link
|
|
105
105
|
//
|
|
106
|
-
// Fix (2026-09-02):
|
|
107
|
-
// target —
|
|
108
|
-
// lookbehind
|
|
109
|
-
//
|
|
110
|
-
// `apps/vela/plan/PLAN-x.md`) — false-negative
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// plain-text mention —
|
|
106
|
+
// Fix (2026-09-02): strip the whole markdown link [text](target) first (both display text and
|
|
107
|
+
// target — not just target), then run the plain-text regex on the remainder, no longer needing
|
|
108
|
+
// lookbehind — the old lookbehind `(?<![/\[(])` was meant to stop markdown-link false-positives
|
|
109
|
+
// but its side effect was excluding every mention with a leading `/` too (e.g. `done/PLAN-x.md` or
|
|
110
|
+
// `apps/vela/plan/PLAN-x.md`) — the real false-negative that slipped through in 6e411042
|
|
111
|
+
// The first fix (stripping only `](target)`) missed — it left `[display-text]` unwrapped, making an already
|
|
112
|
+
// valid link like `[PLAN-x.md](../done/PLAN-x.md)` (the actual pattern throughout this file) get double-counted as a
|
|
113
|
+
// plain-text mention — strip the whole [..](..) block, not just the (..) part
|
|
114
114
|
export const countPlainTextMentions = (
|
|
115
115
|
file: string,
|
|
116
116
|
target: string,
|
|
@@ -128,7 +128,7 @@ export const countPlainTextMentions = (
|
|
|
128
128
|
return count;
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
-
//
|
|
131
|
+
// shared with the dashboard (now/kickoff) — plan/ files with a shipped header but not yet moved into done/
|
|
132
132
|
export const shippedNotMoved = (): string[] => {
|
|
133
133
|
const dir = planDir;
|
|
134
134
|
if (!existsSync(dir)) return [];
|
|
@@ -187,8 +187,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
187
187
|
return;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
// ponytail: --apply
|
|
191
|
-
//
|
|
190
|
+
// ponytail: the old --apply enforced neither of these two conditions — you could pass the dry-run message
|
|
191
|
+
// but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
|
|
192
192
|
if (!shipped && !process.env.MEM_FORCE) {
|
|
193
193
|
console.error(
|
|
194
194
|
`${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
|
|
@@ -211,8 +211,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
211
211
|
process.exit(1);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
// ponytail:
|
|
215
|
-
//
|
|
214
|
+
// ponytail: a file just written this round may not be git add'ed yet — `git mv` fails silently (exit 128, no throw)
|
|
215
|
+
// then the next code hits ENOENT reading a dst that does not exist — always stage first (no-op if already tracked)
|
|
216
216
|
mkdirSync(doneDir, { recursive: true });
|
|
217
217
|
Bun.spawnSync(["git", "add", src]);
|
|
218
218
|
const mv = Bun.spawnSync(["git", "mv", src, dst]);
|
|
@@ -221,8 +221,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
221
221
|
process.exit(1);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// done/
|
|
225
|
-
// layout
|
|
224
|
+
// done/ is a sibling of plan/ = same depth, links in the file still resolve, no need to touch
|
|
225
|
+
// the old layout (plan/done/) is one level deeper, so it does need re-relativizing
|
|
226
226
|
const nested = dirname(doneDir) !== dirname(dir);
|
|
227
227
|
const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
|
|
228
228
|
|
|
@@ -247,7 +247,7 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
247
247
|
`inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
|
|
248
248
|
);
|
|
249
249
|
|
|
250
|
-
// log decision — record ship event (reuse existing kind,
|
|
250
|
+
// log decision — record ship event (reuse existing kind, no new schema)
|
|
251
251
|
const doneSpec = `${rel(dst)}`;
|
|
252
252
|
put({
|
|
253
253
|
id: nextId(rows()),
|
|
@@ -255,11 +255,11 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
255
255
|
text: `${target} shipped → ${rel(dst)}`,
|
|
256
256
|
spec: doneSpec,
|
|
257
257
|
});
|
|
258
|
-
// ponytail: decision
|
|
259
|
-
//
|
|
258
|
+
// ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
|
|
259
|
+
// immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
|
|
260
260
|
put({ kind: "synced", spec: doneSpec });
|
|
261
261
|
|
|
262
|
-
// plain-text mention detection (detect-only,
|
|
262
|
+
// plain-text mention detection (detect-only, no auto-fix)
|
|
263
263
|
let plainTextTotal = 0;
|
|
264
264
|
const plainTextFiles: string[] = [];
|
|
265
265
|
for (const f of mdFiles(dir)) {
|
|
@@ -275,14 +275,14 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
275
275
|
);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
//
|
|
278
|
+
// files outside plan/ that mention the target — detect-only
|
|
279
279
|
const grep = Bun.spawnSync([
|
|
280
280
|
"git",
|
|
281
281
|
"grep",
|
|
282
282
|
"-l",
|
|
283
283
|
target,
|
|
284
284
|
"--",
|
|
285
|
-
//
|
|
285
|
+
// the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
|
|
286
286
|
rel(dirname(planDir)),
|
|
287
287
|
`:!${rel(dir)}`,
|
|
288
288
|
])
|
|
@@ -324,8 +324,8 @@ export const cmdPlanCheck = (a: string[]) => {
|
|
|
324
324
|
// only check active files (not done/) — done/ files are historical snapshots with external refs
|
|
325
325
|
const linkRe = /\]\(([^)]+)\)/g;
|
|
326
326
|
for (const f of active) {
|
|
327
|
-
//
|
|
328
|
-
//
|
|
327
|
+
// strip fenced blocks + inline code first (replace with spaces to preserve line offset) —
|
|
328
|
+
// example links in code (e.g. this tool's own spec) must not be counted as real links
|
|
329
329
|
const src = readFileSync(f, "utf8")
|
|
330
330
|
.replace(/```[\s\S]*?```/g, (b) => b.replace(/[^\n]/g, " "))
|
|
331
331
|
.replace(/`[^`\n]*`/g, (b) => " ".repeat(b.length));
|
|
@@ -22,7 +22,7 @@ const planSweepLine = () => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export const cmdNow = () => {
|
|
25
|
-
// mem now (default) — next+bug+hold. decision/note
|
|
25
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
26
26
|
const all = rows();
|
|
27
27
|
console.log(`# ${app} — ${all.length} entries`);
|
|
28
28
|
printOpenRows(all, { showHold: true });
|
|
@@ -53,7 +53,7 @@ export const cmdStale = () => {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export const cmdFind = (a: string[]) => {
|
|
56
|
-
// mem find
|
|
56
|
+
// mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
|
|
57
57
|
const q = a.join(" ").toLowerCase();
|
|
58
58
|
if (!q) {
|
|
59
59
|
console.error(`usage: ${memCmd} find <word>`);
|
|
@@ -86,7 +86,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
86
86
|
const arg = a[0] ?? "";
|
|
87
87
|
|
|
88
88
|
if (!arg) {
|
|
89
|
-
//
|
|
89
|
+
// no args = now + a "recent" section = the last 10 closes
|
|
90
90
|
console.log(`# ${app} — ${all.length} entries`);
|
|
91
91
|
printOpenRows(all, { showHold: true });
|
|
92
92
|
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
@@ -101,7 +101,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
101
101
|
const rotate = rotateLine(all.length);
|
|
102
102
|
if (rotate) console.log(rotate);
|
|
103
103
|
} else if (arg.endsWith(".md")) {
|
|
104
|
-
// spec.md = brief
|
|
104
|
+
// spec.md = a brief for that spec
|
|
105
105
|
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
106
106
|
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
107
107
|
const open = openRows(all);
|
|
@@ -135,7 +135,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
135
135
|
console.log("(no entries for this spec)");
|
|
136
136
|
}
|
|
137
137
|
} else {
|
|
138
|
-
// id = brief
|
|
138
|
+
// id = a brief for that task
|
|
139
139
|
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
140
140
|
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
141
141
|
const target = byId.get(arg);
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
|
|
2
|
-
//
|
|
3
|
-
// git mv
|
|
2
|
+
// keep open work rows + active claims; the rest (close/release/synced of already-closed refs)
|
|
3
|
+
// is git mv'd to a separate archive file (not deleted) — old history remains, searchable via git log/git show
|
|
4
4
|
|
|
5
5
|
import { existsSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { rotateKeep } from "../selectors.js";
|
|
8
8
|
import { appendRaw, dir, LOG, rows } from "../store.js";
|
|
9
9
|
|
|
10
|
-
// ponytail: threshold =
|
|
11
|
-
// (view
|
|
12
|
-
// 3000
|
|
10
|
+
// ponytail: threshold = total row count, not just open — the fear is a bloated file / slow grep when many people use it at once
|
|
11
|
+
// (the view being unreadable is a separate problem the CAP in write.ts already handles)
|
|
12
|
+
// 3000 estimated from one solo week = ~2k rows — tune with MEM_ROTATE_THRESHOLD if the pace differs a lot from this
|
|
13
13
|
export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
|
|
14
14
|
|
|
15
15
|
export const cmdRotate = (a: string[]) => {
|
|
@@ -40,7 +40,7 @@ export const cmdRotate = (a: string[]) => {
|
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
43
|
+
// like plan-sweep: stage first to stop git mv failing silently if the file is untracked (exit 128)
|
|
44
44
|
Bun.spawnSync(["git", "add", LOG]);
|
|
45
45
|
const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
|
|
46
46
|
if (mv.exitCode !== 0) {
|
|
@@ -37,7 +37,7 @@ const runSelectorTests = () => {
|
|
|
37
37
|
{ id: "a2", kind: "next", text: "work B", ts: "", agent: "" },
|
|
38
38
|
{ kind: "close", ref: "a2", text: "done", ts: "", agent: "" },
|
|
39
39
|
{ kind: "claim", ref: "a1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
40
|
-
// (1) claim
|
|
40
|
+
// (1) single claim → active
|
|
41
41
|
{ id: "a3", kind: "bug", text: "work C", ts: "", agent: "" },
|
|
42
42
|
{ kind: "claim", ref: "a3", ts: "2026-01-01T01:00:00Z", agent: "agent-1" },
|
|
43
43
|
{
|
|
@@ -55,7 +55,7 @@ const runSelectorTests = () => {
|
|
|
55
55
|
{ id: "a5", kind: "next", text: "work E", ts: "", agent: "" },
|
|
56
56
|
{ kind: "claim", ref: "a5", ts: "2026-01-01T04:00:00Z", agent: "agent-1" },
|
|
57
57
|
{ kind: "claim", ref: "a5", ts: "2026-01-01T05:00:00Z", agent: "agent-3" },
|
|
58
|
-
// (4) double claim →
|
|
58
|
+
// (4) double claim → the later one wins (agent-3)
|
|
59
59
|
];
|
|
60
60
|
|
|
61
61
|
const claims = claimsOf(t);
|
|
@@ -70,12 +70,12 @@ const runSelectorTests = () => {
|
|
|
70
70
|
assert(!claims.has("a3"), "release did not void the claim");
|
|
71
71
|
// (3) a4 close → inactive
|
|
72
72
|
assert(!claims.has("a4"), "close void claim");
|
|
73
|
-
// (4) a5 double claim → agent-3
|
|
73
|
+
// (4) a5 double claim → agent-3 wins
|
|
74
74
|
assert(
|
|
75
75
|
claims.has("a5") && claims.get("a5")?.agent === "agent-3",
|
|
76
76
|
"double claim after the winner",
|
|
77
77
|
);
|
|
78
|
-
// a2 closed →
|
|
78
|
+
// a2 closed → should not appear in open
|
|
79
79
|
assert(!open.some((r) => r.id === "a2"), "tombstone broken");
|
|
80
80
|
// open = a1, a3 (released but still open), a5
|
|
81
81
|
assert(
|
|
@@ -91,12 +91,12 @@ const runRotateTests = () => {
|
|
|
91
91
|
const t: LogRow[] = [
|
|
92
92
|
{ id: "r1", kind: "next", text: "keep me", ts: "", agent: "" },
|
|
93
93
|
{ kind: "claim", ref: "r1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
94
|
-
// r1: open + claimed → work row
|
|
94
|
+
// r1: open + claimed → both the work row and the claim row must be kept
|
|
95
95
|
|
|
96
96
|
{ id: "r2", kind: "bug", text: "closed already", ts: "", agent: "" },
|
|
97
97
|
{ kind: "claim", ref: "r2", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
98
98
|
{ kind: "close", ref: "r2", text: "shipped", ts: "", agent: "" },
|
|
99
|
-
// r2:
|
|
99
|
+
// r2: closed → work row, claim row, close tombstone all dropped
|
|
100
100
|
|
|
101
101
|
{
|
|
102
102
|
id: "r3",
|
|
@@ -112,7 +112,7 @@ const runRotateTests = () => {
|
|
|
112
112
|
ts: "2026-01-02T00:00:00Z",
|
|
113
113
|
agent: "",
|
|
114
114
|
},
|
|
115
|
-
// r3: spec synced
|
|
115
|
+
// r3: spec synced *after* this decision → resolved, can be archived
|
|
116
116
|
|
|
117
117
|
{
|
|
118
118
|
id: "r4",
|
|
@@ -122,10 +122,10 @@ const runRotateTests = () => {
|
|
|
122
122
|
agent: "",
|
|
123
123
|
spec: "PLAN-a.md",
|
|
124
124
|
},
|
|
125
|
-
// r4: decision
|
|
125
|
+
// r4: decision newer than the latest synced → not resolved yet, keep it
|
|
126
126
|
|
|
127
127
|
{ id: "r5", kind: "note", text: "note no spec", ts: "", agent: "" },
|
|
128
|
-
// r5: note
|
|
128
|
+
// r5: a note with no spec → no way to know if resolved, always kept
|
|
129
129
|
];
|
|
130
130
|
|
|
131
131
|
const kept = rotateKeep(t);
|
|
@@ -167,17 +167,17 @@ const runPlanSweepTests = () => {
|
|
|
167
167
|
const tmpDir = mkdtempSync(join(tmpdir(), "mem-test-"));
|
|
168
168
|
try {
|
|
169
169
|
// === Case A: rewriteMarkdownLinks + countPlainTextMentions ===
|
|
170
|
-
//
|
|
170
|
+
// build fixture: a file that references plan/PLAN-page-style.md several ways
|
|
171
171
|
const planDir = join(tmpDir, "plan");
|
|
172
172
|
const doneDir = join(tmpDir, "plan", "done");
|
|
173
173
|
mkdirSync(planDir, { recursive: true });
|
|
174
174
|
mkdirSync(doneDir, { recursive: true });
|
|
175
175
|
|
|
176
|
-
// "old" file —
|
|
176
|
+
// "old" file — simulates apps/vela/plan/PLAN-page-style.md (touch only)
|
|
177
177
|
const oldFile = join(planDir, "PLAN-page-style.md");
|
|
178
178
|
writeFileSync(oldFile, "# PLAN-page-style\n");
|
|
179
179
|
|
|
180
|
-
// inbound file —
|
|
180
|
+
// inbound file — simulates apps/vela/plan/PLAN-people-style.md
|
|
181
181
|
const inbound = [
|
|
182
182
|
"# PLAN-people-style — refs",
|
|
183
183
|
"",
|
|
@@ -361,13 +361,13 @@ const runPlanCheckTests = () => {
|
|
|
361
361
|
);
|
|
362
362
|
writeFileSync(join(planDir, "PLAN-active.md"), "# active plan\n");
|
|
363
363
|
|
|
364
|
-
// plan
|
|
364
|
+
// a plan in the current format: frontmatter + title come before the ✅ shipped header
|
|
365
365
|
writeFileSync(
|
|
366
366
|
join(planDir, "PLAN-frontmatter.md"),
|
|
367
367
|
"---\nkind: unit\n---\n\n# shipped with frontmatter\n\n> ✅ **shipped 2026-09-13** (abc1234)\n",
|
|
368
368
|
);
|
|
369
369
|
|
|
370
|
-
//
|
|
370
|
+
// shipped some chunks and waiting on externals — frontmatter says it is meant to stay, do not count as forgotten
|
|
371
371
|
writeFileSync(
|
|
372
372
|
join(planDir, "PLAN-blocked.md"),
|
|
373
373
|
"---\nstatus: blocked\nblocked_by: VPS#2\n---\n\n# partly shipped\n\n> ✅ **chunk 1 shipped 2026-09-13** (abc1234)\n",
|
|
@@ -7,17 +7,17 @@ import { KINDS, memCmd, nextId, put, root, rows } from "../store.js";
|
|
|
7
7
|
export const cmdAdd = async (a: string[]) => {
|
|
8
8
|
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
9
9
|
// mem add <kind> --stdin --files f1,f2 [spec.md] ← read text from stdin (avoids shell metachar issues)
|
|
10
|
-
// ponytail: kind
|
|
10
|
+
// ponytail: a wrong kind = that row silently vanishes from the view — better to die right here
|
|
11
11
|
if (!KINDS.includes(a[0] as WorkKind)) {
|
|
12
12
|
console.error(
|
|
13
13
|
`kind must be one of ${KINDS.join("|")} — got "${a[0] ?? ""}"`,
|
|
14
14
|
);
|
|
15
15
|
process.exit(1);
|
|
16
16
|
}
|
|
17
|
-
// hold
|
|
18
|
-
// PLAN-convention-debt chunk 3: files[]
|
|
19
|
-
// fill rate
|
|
20
|
-
// 48.1% —
|
|
17
|
+
// hold requires a spec — checked after parse (the spec may come before --files)
|
|
18
|
+
// PLAN-convention-debt chunk 3: files[] is required — a measured optional field
|
|
19
|
+
// had fill rate 0 (required+enum = 50/50) and recall on old logs without files[] caught only
|
|
20
|
+
// 48.1% — fix the write side, not the read side: a row that does not name the file = unfindable when you touch that file
|
|
21
21
|
const filesFlagIdx = a.indexOf("--files");
|
|
22
22
|
const filesVal = filesFlagIdx >= 0 ? a[filesFlagIdx + 1] : undefined;
|
|
23
23
|
if (!filesVal || filesVal.startsWith("--")) {
|
|
@@ -56,7 +56,7 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
56
56
|
process.exit(1);
|
|
57
57
|
}
|
|
58
58
|
} else {
|
|
59
|
-
//
|
|
59
|
+
// text is required
|
|
60
60
|
if (!filtered.length || (filtered.length === 0 && !spec)) {
|
|
61
61
|
console.error(
|
|
62
62
|
`text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
|
|
@@ -67,7 +67,7 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
67
67
|
}
|
|
68
68
|
// read once — cap check + id collision
|
|
69
69
|
const all = rows();
|
|
70
|
-
// ponytail:
|
|
70
|
+
// ponytail: a cap on open next/hold stops endless accumulation — forces triage of the old before opening new
|
|
71
71
|
const CAP = 15;
|
|
72
72
|
const CAP_HOLD = 10;
|
|
73
73
|
if (a[0] === "next" && !process.env.MEM_FORCE) {
|
|
@@ -88,14 +88,14 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
88
88
|
process.exit(1);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
// ponytail:
|
|
91
|
+
// ponytail: prevent id collisions — logic centralized in nextId (store.ts)
|
|
92
92
|
const id = nextId(all);
|
|
93
93
|
put({ id, kind: a[0] as WorkKind, text, spec, files });
|
|
94
94
|
console.log(id);
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
export const cmdClose = async (a: string[]) => {
|
|
98
|
-
// mem close <id> "
|
|
98
|
+
// mem close <id> "<what was done / commit>" — the tombstone voids the claim by itself
|
|
99
99
|
// mem close <id> --stdin ← read text from stdin
|
|
100
100
|
if (!a[0]) {
|
|
101
101
|
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
@@ -117,7 +117,7 @@ export const cmdClose = async (a: string[]) => {
|
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
export const cmdClaim = (a: string[]) => {
|
|
120
|
-
// mem claim <id> —
|
|
120
|
+
// mem claim <id> — id must be real, open, kind=next|bug, with no active claim pending
|
|
121
121
|
if (!a[0]) {
|
|
122
122
|
console.error(`id is required — usage: ${memCmd} claim <id>`);
|
|
123
123
|
process.exit(1);
|
|
@@ -153,7 +153,7 @@ export const cmdClaim = (a: string[]) => {
|
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
export const cmdRelease = async (a: string[]) => {
|
|
156
|
-
// mem release <id> "
|
|
156
|
+
// mem release <id> "<reason>?"
|
|
157
157
|
// mem release <id> --stdin ← read text from stdin
|
|
158
158
|
if (!a[0]) {
|
|
159
159
|
console.error(`id is required — usage: ${memCmd} release <id> [reason]`);
|
|
@@ -179,7 +179,7 @@ export const cmdRelease = async (a: string[]) => {
|
|
|
179
179
|
};
|
|
180
180
|
|
|
181
181
|
export const cmdSynced = (a: string[]) => {
|
|
182
|
-
// mem synced [path.md ...] —
|
|
182
|
+
// mem synced [path.md ...] — declare the spec now matches the log (none given = every spec the log mentions)
|
|
183
183
|
const specs = a.length
|
|
184
184
|
? a
|
|
185
185
|
: [
|
|
@@ -195,7 +195,7 @@ export const cmdSynced = (a: string[]) => {
|
|
|
195
195
|
};
|
|
196
196
|
|
|
197
197
|
export const cmdHook = async () => {
|
|
198
|
-
// PostToolUse: stdin = {tool_input:{file_path}} →
|
|
198
|
+
// PostToolUse: stdin = {tool_input:{file_path}} → editing this app's spec is done = mark synced automatically
|
|
199
199
|
const j = (await Bun.stdin.json().catch(() => null)) as Record<
|
|
200
200
|
string,
|
|
201
201
|
unknown
|
package/templates/mem/mem.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
// append-only memory log.
|
|
4
|
-
// log
|
|
5
|
-
//
|
|
6
|
-
// (repo
|
|
3
|
+
// append-only memory log. Never edit old lines — close a task = close (tombstone), git = history
|
|
4
|
+
// the log always sits beside this file when placed by `fapony init` (<project>/.fapony/.memory/log.jsonl);
|
|
5
|
+
// the central copy in a monorepo (single code copy at the root) still splits logs per app: apps/<app>/.fapony/.memory/log.jsonl
|
|
6
|
+
// (a repo not yet migrated with an old log at apps/<app>/.memory/log.jsonl keeps reading that location)
|
|
7
7
|
//
|
|
8
8
|
// CLI entry point — all logic lives in:
|
|
9
9
|
// store.ts (types + config + rows/put)
|
|
@@ -63,6 +63,6 @@ if (cmd === "add") {
|
|
|
63
63
|
} else if (cmd === "rotate") {
|
|
64
64
|
cmdRotate(a);
|
|
65
65
|
} else {
|
|
66
|
-
// mem now (default) — next+bug+hold. decision/note
|
|
66
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
67
67
|
cmdNow();
|
|
68
68
|
}
|
|
@@ -8,8 +8,8 @@ import type {
|
|
|
8
8
|
WorkRow,
|
|
9
9
|
} from "./store.js";
|
|
10
10
|
|
|
11
|
-
// ponytail: reducer = filter + tombstone set. 50k
|
|
12
|
-
//
|
|
11
|
+
// ponytail: reducer = filter + tombstone set. 50k lines = 9.6MB/26ms — the file is not the bottleneck
|
|
12
|
+
// the real ceiling = too many open rows for the view to make sense → rotate then: git mv + append openRows(old) into a new file
|
|
13
13
|
export const openRows = (all: LogRow[]): WorkRow[] => {
|
|
14
14
|
const dead = new Set(
|
|
15
15
|
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
@@ -25,8 +25,8 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
|
|
|
25
25
|
);
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
// C) claimsOf:
|
|
29
|
-
// active =
|
|
28
|
+
// C) claimsOf: returns Map ref → latest claim row still active
|
|
29
|
+
// active = the latest claim|release row is a claim and ref is not in dead (close)
|
|
30
30
|
export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
|
|
31
31
|
const dead = new Set(
|
|
32
32
|
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
@@ -46,9 +46,9 @@ export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
|
|
|
46
46
|
return active;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
// decision
|
|
50
|
-
// + next/hold
|
|
51
|
-
// + active claim
|
|
49
|
+
// a decision newer than both the spec's latest commit and the synced marker = spec not updated to match
|
|
50
|
+
// + next/hold whose spec was edited after creation = may be done already but nobody closed it — real case: msbnndmt
|
|
51
|
+
// + an active claim older than 4h = the agent may have died mid-task
|
|
52
52
|
export const staleReport = (all: LogRow[]): string[] => {
|
|
53
53
|
const out: string[] = [];
|
|
54
54
|
const mark: Record<string, number> = {};
|
|
@@ -67,7 +67,7 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
67
67
|
}
|
|
68
68
|
const gitDates = new Map<string, number>();
|
|
69
69
|
for (const spec of specPaths) {
|
|
70
|
-
// --follow: spec path
|
|
70
|
+
// --follow: a spec path that was git mv'd (e.g. moved into plan/done/) can still follow its history
|
|
71
71
|
const git = Bun.spawnSync([
|
|
72
72
|
"git",
|
|
73
73
|
"log",
|
|
@@ -94,9 +94,9 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
94
94
|
for (const r of openRows(all)) {
|
|
95
95
|
if ((r.kind === "next" || r.kind === "hold") && r.spec) {
|
|
96
96
|
const gitDate = gitDates.get(r.spec);
|
|
97
|
-
// ponytail: grace
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// ponytail: 24h grace — "log next then write/commit the plan the same day" is normal workflow
|
|
98
|
+
// not a signal that the work finished and was forgotten (the real case to catch, msbnndmt, is days apart) —
|
|
99
|
+
// without grace = SUSPECT fires on every freshly written plan, and everyone learns to skim past the whole stale block
|
|
100
100
|
if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
|
|
101
101
|
out.push(
|
|
102
102
|
`SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
|
|
@@ -115,13 +115,13 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
115
115
|
return out;
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
-
// rotate:
|
|
119
|
-
// - next/bug/hold
|
|
120
|
-
// - decision/note
|
|
121
|
-
// spec
|
|
122
|
-
// (logic
|
|
123
|
-
// ponytail: decision/note
|
|
124
|
-
//
|
|
118
|
+
// rotate: rows that must carry over into the new log file after archiving
|
|
119
|
+
// - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
|
|
120
|
+
// - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
|
|
121
|
+
// spec was synced *after* this row = the info really made it into the spec, archive (git) is enough, no need to carry it in the hot file
|
|
122
|
+
// (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
|
|
123
|
+
// ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
|
|
124
|
+
// attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
|
|
125
125
|
export const rotateKeep = (all: LogRow[]): LogRow[] => {
|
|
126
126
|
const open = openRows(all);
|
|
127
127
|
const workOpen = open.filter(
|