fapony 0.1.0
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/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// commands/selftest.ts — fixture tests for selectors + plan-sweep link rewrite functions
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { claimsOf, openRows, rotateKeep } from "../selectors.js";
|
|
15
|
+
import type { LogRow } from "../store.js";
|
|
16
|
+
import {
|
|
17
|
+
countPlainTextMentions,
|
|
18
|
+
hasShippedHeader,
|
|
19
|
+
rewriteMarkdownLinks,
|
|
20
|
+
rewriteMovedFileLinks,
|
|
21
|
+
} from "./plan.js";
|
|
22
|
+
|
|
23
|
+
const assert = (cond: boolean, msg: string) => {
|
|
24
|
+
if (!cond) throw new Error(`ASSERT FAILED: ${msg}`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// fixture helper — creates files in os.tmpdir(), auto-cleanup in finally
|
|
28
|
+
const makeFixture = (dir: string, name: string, body: string): string => {
|
|
29
|
+
const p = join(dir, name);
|
|
30
|
+
writeFileSync(p, body);
|
|
31
|
+
return p;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const runSelectorTests = () => {
|
|
35
|
+
const t: LogRow[] = [
|
|
36
|
+
{ id: "a1", kind: "next", text: "work A", ts: "", agent: "" },
|
|
37
|
+
{ id: "a2", kind: "next", text: "work B", ts: "", agent: "" },
|
|
38
|
+
{ kind: "close", ref: "a2", text: "done", ts: "", agent: "" },
|
|
39
|
+
{ kind: "claim", ref: "a1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
40
|
+
// (1) claim เดียว → active
|
|
41
|
+
{ id: "a3", kind: "bug", text: "work C", ts: "", agent: "" },
|
|
42
|
+
{ kind: "claim", ref: "a3", ts: "2026-01-01T01:00:00Z", agent: "agent-1" },
|
|
43
|
+
{
|
|
44
|
+
kind: "release",
|
|
45
|
+
ref: "a3",
|
|
46
|
+
text: "paused",
|
|
47
|
+
ts: "2026-01-01T02:00:00Z",
|
|
48
|
+
agent: "agent-1",
|
|
49
|
+
},
|
|
50
|
+
// (2) release → inactive
|
|
51
|
+
{ id: "a4", kind: "next", text: "work D", ts: "", agent: "" },
|
|
52
|
+
{ kind: "claim", ref: "a4", ts: "2026-01-01T03:00:00Z", agent: "agent-2" },
|
|
53
|
+
{ kind: "close", ref: "a4", text: "shipped", ts: "", agent: "" },
|
|
54
|
+
// (3) claim+close → inactive
|
|
55
|
+
{ id: "a5", kind: "next", text: "work E", ts: "", agent: "" },
|
|
56
|
+
{ kind: "claim", ref: "a5", ts: "2026-01-01T04:00:00Z", agent: "agent-1" },
|
|
57
|
+
{ kind: "claim", ref: "a5", ts: "2026-01-01T05:00:00Z", agent: "agent-3" },
|
|
58
|
+
// (4) double claim → หลังชนะ (agent-3)
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const claims = claimsOf(t);
|
|
62
|
+
const open = openRows(t);
|
|
63
|
+
|
|
64
|
+
// (1) a1 claim → active
|
|
65
|
+
assert(
|
|
66
|
+
claims.has("a1") && claims.get("a1")?.agent === "agent-1",
|
|
67
|
+
"claim active is wrong",
|
|
68
|
+
);
|
|
69
|
+
// (2) a3 release → inactive
|
|
70
|
+
assert(!claims.has("a3"), "release did not void the claim");
|
|
71
|
+
// (3) a4 close → inactive
|
|
72
|
+
assert(!claims.has("a4"), "close void claim");
|
|
73
|
+
// (4) a5 double claim → agent-3 ชนะ
|
|
74
|
+
assert(
|
|
75
|
+
claims.has("a5") && claims.get("a5")?.agent === "agent-3",
|
|
76
|
+
"double claim after the winner",
|
|
77
|
+
);
|
|
78
|
+
// a2 closed → ไม่ควรอยู่ใน open
|
|
79
|
+
assert(!open.some((r) => r.id === "a2"), "tombstone broken");
|
|
80
|
+
// open = a1, a3 (released but still open), a5
|
|
81
|
+
assert(
|
|
82
|
+
open
|
|
83
|
+
.map((r) => r.id)
|
|
84
|
+
.sort()
|
|
85
|
+
.join() === "a1,a3,a5",
|
|
86
|
+
"open rows are wrong",
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const runRotateTests = () => {
|
|
91
|
+
const t: LogRow[] = [
|
|
92
|
+
{ id: "r1", kind: "next", text: "keep me", ts: "", agent: "" },
|
|
93
|
+
{ kind: "claim", ref: "r1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
94
|
+
// r1: open + claimed → work row + claim row ต้องเก็บทั้งคู่
|
|
95
|
+
|
|
96
|
+
{ id: "r2", kind: "bug", text: "closed already", ts: "", agent: "" },
|
|
97
|
+
{ kind: "claim", ref: "r2", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
98
|
+
{ kind: "close", ref: "r2", text: "shipped", ts: "", agent: "" },
|
|
99
|
+
// r2: ปิดแล้ว → work row, claim row, close tombstone ทิ้งหมด
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
id: "r3",
|
|
103
|
+
kind: "decision",
|
|
104
|
+
text: "resolved decision",
|
|
105
|
+
ts: "2026-01-01T00:00:00Z",
|
|
106
|
+
agent: "",
|
|
107
|
+
spec: "PLAN-a.md",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
kind: "synced",
|
|
111
|
+
spec: "PLAN-a.md",
|
|
112
|
+
ts: "2026-01-02T00:00:00Z",
|
|
113
|
+
agent: "",
|
|
114
|
+
},
|
|
115
|
+
// r3: spec synced *หลัง* decision นี้ → resolved แล้ว archive ได้
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
id: "r4",
|
|
119
|
+
kind: "decision",
|
|
120
|
+
text: "still relevant decision",
|
|
121
|
+
ts: "2026-01-03T00:00:00Z",
|
|
122
|
+
agent: "",
|
|
123
|
+
spec: "PLAN-a.md",
|
|
124
|
+
},
|
|
125
|
+
// r4: decision ใหม่กว่า synced ล่าสุด → ยังไม่ resolved เก็บไว้
|
|
126
|
+
|
|
127
|
+
{ id: "r5", kind: "note", text: "note no spec", ts: "", agent: "" },
|
|
128
|
+
// r5: note ไม่มี spec → ไม่มีทางรู้ resolved หรือยัง เก็บไว้เสมอ
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const kept = rotateKeep(t);
|
|
132
|
+
assert(
|
|
133
|
+
kept.some((r) => "id" in r && r.id === "r1" && r.kind === "next"),
|
|
134
|
+
"rotateKeep: must keep an open work row",
|
|
135
|
+
);
|
|
136
|
+
assert(
|
|
137
|
+
kept.some((r) => r.kind === "claim" && "ref" in r && r.ref === "r1"),
|
|
138
|
+
"rotateKeep: must keep an active claim on an open row",
|
|
139
|
+
);
|
|
140
|
+
assert(
|
|
141
|
+
!kept.some((r) => "id" in r && r.id === "r2"),
|
|
142
|
+
"rotateKeep: must not keep a closed work row",
|
|
143
|
+
);
|
|
144
|
+
assert(
|
|
145
|
+
!kept.some((r) => r.kind === "claim" && "ref" in r && r.ref === "r2"),
|
|
146
|
+
"rotateKeep: must not keep a claim on a closed ref",
|
|
147
|
+
);
|
|
148
|
+
assert(
|
|
149
|
+
!kept.some((r) => r.kind === "close"),
|
|
150
|
+
"rotateKeep: must not keep a close tombstone",
|
|
151
|
+
);
|
|
152
|
+
assert(
|
|
153
|
+
!kept.some((r) => "id" in r && r.id === "r3"),
|
|
154
|
+
"rotateKeep: decision whose spec synced afterwards is resolved — drop it",
|
|
155
|
+
);
|
|
156
|
+
assert(
|
|
157
|
+
kept.some((r) => "id" in r && r.id === "r4"),
|
|
158
|
+
"rotateKeep: decision newer than the last sync is still relevant — keep it",
|
|
159
|
+
);
|
|
160
|
+
assert(
|
|
161
|
+
kept.some((r) => "id" in r && r.id === "r5"),
|
|
162
|
+
"rotateKeep: a note with no spec is always kept",
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const runPlanSweepTests = () => {
|
|
167
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "mem-test-"));
|
|
168
|
+
try {
|
|
169
|
+
// === Case A: rewriteMarkdownLinks + countPlainTextMentions ===
|
|
170
|
+
// สร้าง fixture: ไฟล์ที่ reference ไปที่ plan/PLAN-page-style.md หลายแบบ
|
|
171
|
+
const planDir = join(tmpDir, "plan");
|
|
172
|
+
const doneDir = join(tmpDir, "plan", "done");
|
|
173
|
+
mkdirSync(planDir, { recursive: true });
|
|
174
|
+
mkdirSync(doneDir, { recursive: true });
|
|
175
|
+
|
|
176
|
+
// "old" file — จำลอง apps/vela/plan/PLAN-page-style.md (touch only)
|
|
177
|
+
const oldFile = join(planDir, "PLAN-page-style.md");
|
|
178
|
+
writeFileSync(oldFile, "# PLAN-page-style\n");
|
|
179
|
+
|
|
180
|
+
// inbound file — จำลอง apps/vela/plan/PLAN-people-style.md
|
|
181
|
+
const inbound = [
|
|
182
|
+
"# PLAN-people-style — refs",
|
|
183
|
+
"",
|
|
184
|
+
"[txt](PLAN-page-style.md)", // markdown link → should be rebased
|
|
185
|
+
"look at PLAN-page-style.md for details", // plain text → detect only
|
|
186
|
+
"`PLAN-page-style.md` in backtick", // backtick → detect only
|
|
187
|
+
"```",
|
|
188
|
+
"PLAN-page-style.md", // code fence → detect only
|
|
189
|
+
"```",
|
|
190
|
+
"see PLAN-page-style §1.5 for tone", // bare (no .md) with section ref → detect only
|
|
191
|
+
].join("\n");
|
|
192
|
+
const inboundFile = join(planDir, "PLAN-people-style.md");
|
|
193
|
+
writeFileSync(inboundFile, inbound);
|
|
194
|
+
|
|
195
|
+
// A1: rewriteMarkdownLinks — rebase markdown link [txt](PLAN-page-style.md) → [txt](../done/PLAN-page-style.md)
|
|
196
|
+
const oldAbs = join(planDir, "PLAN-page-style.md");
|
|
197
|
+
const newAbs = join(doneDir, "PLAN-page-style.md");
|
|
198
|
+
const mdCount = rewriteMarkdownLinks(inboundFile, oldAbs, newAbs);
|
|
199
|
+
assert(
|
|
200
|
+
mdCount === 1,
|
|
201
|
+
`rewriteMarkdownLinks should fix 1 markdown link, got ${mdCount}`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// A2: countPlainTextMentions — should detect all plain text variants (not markdown links)
|
|
205
|
+
const ptCount = countPlainTextMentions(inboundFile, "PLAN-page-style.md");
|
|
206
|
+
// after rewriteMarkdownLinks, the markdown link is now [txt](../done/...) — no longer a plain text match
|
|
207
|
+
// remaining: plain text (line 3), backtick (line 4), code fence (line 6), bare with § (line 8) = 4
|
|
208
|
+
assert(ptCount === 4, `countPlainTextMentions should be 4, got ${ptCount}`);
|
|
209
|
+
|
|
210
|
+
// A3: assert file content — markdown link was rebased, plain text untouched
|
|
211
|
+
const afterA = readFileSync(inboundFile, "utf8");
|
|
212
|
+
assert(
|
|
213
|
+
afterA.includes("[txt](done/PLAN-page-style.md)"),
|
|
214
|
+
"markdown link should be rebased to done/",
|
|
215
|
+
);
|
|
216
|
+
assert(
|
|
217
|
+
afterA.includes("look at PLAN-page-style.md for details"),
|
|
218
|
+
"plain text should remain unchanged",
|
|
219
|
+
);
|
|
220
|
+
assert(
|
|
221
|
+
afterA.includes("`PLAN-page-style.md` in backtick"),
|
|
222
|
+
"backtick should remain unchanged",
|
|
223
|
+
);
|
|
224
|
+
assert(
|
|
225
|
+
afterA.includes("see PLAN-page-style §1.5 for tone"),
|
|
226
|
+
"bare name with § should remain unchanged",
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// === Case B: rewriteMovedFileLinks — file moved, target stayed in old dir ===
|
|
230
|
+
// Create sibling file in planDir (stayed behind when main file moved to doneDir)
|
|
231
|
+
makeFixture(planDir, "PLAN-nav.md", "# PLAN-nav\n");
|
|
232
|
+
const movedFile = join(doneDir, "PLAN-page-style.md");
|
|
233
|
+
writeFileSync(
|
|
234
|
+
movedFile,
|
|
235
|
+
[
|
|
236
|
+
"# PLAN-page-style",
|
|
237
|
+
"[sibling](PLAN-nav.md)", // sibling in old dir → rebase to ../
|
|
238
|
+
"[external](https://example.com)", // external → skip
|
|
239
|
+
].join("\n"),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const movedCount = rewriteMovedFileLinks(movedFile, planDir, doneDir);
|
|
243
|
+
assert(
|
|
244
|
+
movedCount === 1,
|
|
245
|
+
`rewriteMovedFileLinks should fix 1 link, got ${movedCount}`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const afterB = readFileSync(movedFile, "utf8");
|
|
249
|
+
assert(
|
|
250
|
+
afterB.includes("[sibling](../PLAN-nav.md)"),
|
|
251
|
+
"sibling link should rebase to ../PLAN-nav.md (target stayed in plan/)",
|
|
252
|
+
);
|
|
253
|
+
assert(
|
|
254
|
+
afterB.includes("[external](https://example.com)"),
|
|
255
|
+
"external link should be untouched",
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// === Case B2: rewriteMovedFileLinks — both source and target in same dir after move ===
|
|
259
|
+
// This is the actual bug: when both files move from plan/ to plan/done/, the outbound
|
|
260
|
+
// link should be same-dir relative (just the filename), not ../filename
|
|
261
|
+
makeFixture(doneDir, "PLAN-page-style.md", "# PLAN-page-style\n");
|
|
262
|
+
const movedFile2 = join(doneDir, "PLAN-people-style.md");
|
|
263
|
+
writeFileSync(
|
|
264
|
+
movedFile2,
|
|
265
|
+
[
|
|
266
|
+
"# PLAN-people-style",
|
|
267
|
+
"[page](PLAN-page-style.md)", // target also in done/ → same-dir relative
|
|
268
|
+
"[nav](PLAN-nav.md)", // target stayed in plan/ → ../
|
|
269
|
+
].join("\n"),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const movedCount2 = rewriteMovedFileLinks(movedFile2, planDir, doneDir);
|
|
273
|
+
assert(
|
|
274
|
+
movedCount2 === 2,
|
|
275
|
+
`rewriteMovedFileLinks B2 should fix 2 links, got ${movedCount2}`,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const afterB2 = readFileSync(movedFile2, "utf8");
|
|
279
|
+
assert(
|
|
280
|
+
afterB2.includes("[page](PLAN-page-style.md)"),
|
|
281
|
+
"B2: same-dir link should stay as filename (both in done/)",
|
|
282
|
+
);
|
|
283
|
+
assert(
|
|
284
|
+
afterB2.includes("[nav](../PLAN-nav.md)"),
|
|
285
|
+
"B2: cross-dir link should rebase to ../ (target stayed in plan/)",
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// === Case C: countPlainTextMentions edge case — bare name (no .md) ===
|
|
289
|
+
const edgeFile = join(tmpDir, "edge.md");
|
|
290
|
+
writeFileSync(edgeFile, "see PLAN-page-style for details\n");
|
|
291
|
+
const edgeCount = countPlainTextMentions(edgeFile, "PLAN-page-style.md");
|
|
292
|
+
assert(
|
|
293
|
+
edgeCount === 1,
|
|
294
|
+
`bare PLAN-page-style (no .md) should count = 1, got ${edgeCount}`,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// edge: no match
|
|
298
|
+
const noMatchFile = join(tmpDir, "nomatch.md");
|
|
299
|
+
writeFileSync(noMatchFile, "nothing here about plans\n");
|
|
300
|
+
const noCount = countPlainTextMentions(noMatchFile, "PLAN-page-style.md");
|
|
301
|
+
assert(noCount === 0, `no match should return 0, got ${noCount}`);
|
|
302
|
+
|
|
303
|
+
// === Case D: regex fix — path-prefixed mentions (§1 root cause fix) ===
|
|
304
|
+
const d1 = join(tmpDir, "d1.md");
|
|
305
|
+
writeFileSync(d1, "`done/PLAN-page-style.md`");
|
|
306
|
+
assert(
|
|
307
|
+
countPlainTextMentions(d1, "PLAN-page-style.md") === 1,
|
|
308
|
+
"D1: backtick with path prefix should detect (was 0 before fix)",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const d2 = join(tmpDir, "d2.md");
|
|
312
|
+
writeFileSync(d2, "apps/vela/plan/PLAN-page-style.md");
|
|
313
|
+
assert(
|
|
314
|
+
countPlainTextMentions(d2, "PLAN-page-style.md") === 1,
|
|
315
|
+
"D2: plain text with path prefix should detect (was 0 before fix)",
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const d3 = join(tmpDir, "d3.md");
|
|
319
|
+
writeFileSync(d3, "[x](PLAN-page-style.md)");
|
|
320
|
+
assert(
|
|
321
|
+
countPlainTextMentions(d3, "PLAN-page-style.md") === 0,
|
|
322
|
+
"D3: markdown link should not double-count",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const d4 = join(tmpDir, "d4.md");
|
|
326
|
+
writeFileSync(d4, "done/PLAN-page-style.md and `PLAN-page-style.md`");
|
|
327
|
+
assert(
|
|
328
|
+
countPlainTextMentions(d4, "PLAN-page-style.md") === 2,
|
|
329
|
+
"D4: multiple mentions with path prefix should count all",
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// D5: regression guard — markdown link whose display text equals the target filename
|
|
333
|
+
// (actual repo convention, e.g. [PLAN-page-style.md](../done/PLAN-page-style.md)) must NOT
|
|
334
|
+
// be double-counted as a plain-text mention — first attempt at this fix only stripped the
|
|
335
|
+
// `(target)` part and left `[display-text]` unguarded, miscounting every such link
|
|
336
|
+
const d5 = join(tmpDir, "d5.md");
|
|
337
|
+
writeFileSync(
|
|
338
|
+
d5,
|
|
339
|
+
"see [PLAN-page-style.md](../done/PLAN-page-style.md) §1.5.4 for tone",
|
|
340
|
+
);
|
|
341
|
+
assert(
|
|
342
|
+
countPlainTextMentions(d5, "PLAN-page-style.md") === 0,
|
|
343
|
+
"D5: markdown link with matching display text should not double-count",
|
|
344
|
+
);
|
|
345
|
+
} finally {
|
|
346
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const runPlanCheckTests = () => {
|
|
351
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "mem-pc-"));
|
|
352
|
+
try {
|
|
353
|
+
const planDir = join(tmpDir, "plan");
|
|
354
|
+
const doneDir = join(planDir, "done");
|
|
355
|
+
mkdirSync(doneDir, { recursive: true });
|
|
356
|
+
|
|
357
|
+
// E1: shipped-not-moved detection
|
|
358
|
+
writeFileSync(
|
|
359
|
+
join(planDir, "PLAN-shipped.md"),
|
|
360
|
+
"> ✅ **Scope:** test\n# shipped plan\n",
|
|
361
|
+
);
|
|
362
|
+
writeFileSync(join(planDir, "PLAN-active.md"), "# active plan\n");
|
|
363
|
+
|
|
364
|
+
// plan ที่เขียนด้วย format ปัจจุบัน: frontmatter + title มาก่อน header ✅ shipped
|
|
365
|
+
writeFileSync(
|
|
366
|
+
join(planDir, "PLAN-frontmatter.md"),
|
|
367
|
+
"---\nkind: unit\n---\n\n# shipped with frontmatter\n\n> ✅ **shipped 2026-09-13** (abc1234)\n",
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// ship ไปบาง chunk แล้วติดรอของข้างนอก — frontmatter บอกว่าตั้งใจให้อยู่ต่อ ห้ามนับว่าลืมย้าย
|
|
371
|
+
writeFileSync(
|
|
372
|
+
join(planDir, "PLAN-blocked.md"),
|
|
373
|
+
"---\nstatus: blocked\nblocked_by: VPS#2\n---\n\n# partly shipped\n\n> ✅ **chunk 1 shipped 2026-09-13** (abc1234)\n",
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const mdFilesLocal = (dir: string): string[] =>
|
|
377
|
+
existsSync(dir)
|
|
378
|
+
? readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
379
|
+
e.isDirectory()
|
|
380
|
+
? mdFilesLocal(join(dir, e.name))
|
|
381
|
+
: e.name.endsWith(".md")
|
|
382
|
+
? [join(dir, e.name)]
|
|
383
|
+
: [],
|
|
384
|
+
)
|
|
385
|
+
: [];
|
|
386
|
+
|
|
387
|
+
const activeFiles = mdFilesLocal(planDir).filter(
|
|
388
|
+
(f) => !f.includes("/done/"),
|
|
389
|
+
);
|
|
390
|
+
const shipped = activeFiles.filter(hasShippedHeader);
|
|
391
|
+
assert(
|
|
392
|
+
shipped.length === 2,
|
|
393
|
+
`E1: should find 2 shipped-not-moved, got ${shipped.length}`,
|
|
394
|
+
);
|
|
395
|
+
assert(
|
|
396
|
+
shipped.some((f) => f.includes("PLAN-shipped.md")),
|
|
397
|
+
"E1: should detect PLAN-shipped.md",
|
|
398
|
+
);
|
|
399
|
+
assert(
|
|
400
|
+
shipped.some((f) => f.includes("PLAN-frontmatter.md")),
|
|
401
|
+
"E1: header below frontmatter + title must still count as shipped",
|
|
402
|
+
);
|
|
403
|
+
assert(
|
|
404
|
+
!shipped.some((f) => f.includes("PLAN-blocked.md")),
|
|
405
|
+
"E1: status: blocked must beat the ✅ header — it is held on purpose, not forgotten",
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// E2: broken link detection
|
|
409
|
+
writeFileSync(
|
|
410
|
+
join(planDir, "PLAN-broken.md"),
|
|
411
|
+
"# broken\n[ref](PLAN-notexist.md)\n",
|
|
412
|
+
);
|
|
413
|
+
const allFiles = mdFilesLocal(planDir);
|
|
414
|
+
const linkRe = /\]\(([^)]+)\)/g;
|
|
415
|
+
const broken: string[] = [];
|
|
416
|
+
for (const f of allFiles) {
|
|
417
|
+
const src = readFileSync(f, "utf8");
|
|
418
|
+
let m: RegExpExecArray | null;
|
|
419
|
+
while ((m = linkRe.exec(src)) !== null) {
|
|
420
|
+
const target = m[1];
|
|
421
|
+
if (!target || /^(https?:|mailto:|\/)/.test(target)) continue;
|
|
422
|
+
const [pathPart] = target.split("#");
|
|
423
|
+
if (!pathPart) continue;
|
|
424
|
+
const resolved = resolve(dirname(f), pathPart);
|
|
425
|
+
if (!resolved.startsWith(planDir)) continue;
|
|
426
|
+
if (!existsSync(resolved)) {
|
|
427
|
+
broken.push(`${f} → ${target}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
assert(
|
|
432
|
+
broken.length === 1,
|
|
433
|
+
`E2: should find 1 broken link, got ${broken.length}`,
|
|
434
|
+
);
|
|
435
|
+
assert(
|
|
436
|
+
broken[0]?.includes("PLAN-notexist.md") === true,
|
|
437
|
+
"E2: broken link should reference PLAN-notexist.md",
|
|
438
|
+
);
|
|
439
|
+
} finally {
|
|
440
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
export const cmdTest = () => {
|
|
445
|
+
runSelectorTests();
|
|
446
|
+
runRotateTests();
|
|
447
|
+
runPlanSweepTests();
|
|
448
|
+
runPlanCheckTests();
|
|
449
|
+
console.log("ok");
|
|
450
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// commands/write.ts — mutating commands: add, close, claim, release, synced, hook
|
|
2
|
+
|
|
3
|
+
import { claimsOf, openRows } from "../selectors.js";
|
|
4
|
+
import type { WorkKind } from "../store.js";
|
|
5
|
+
import { KINDS, memCmd, nextId, put, root, rows } from "../store.js";
|
|
6
|
+
|
|
7
|
+
export const cmdAdd = async (a: string[]) => {
|
|
8
|
+
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
9
|
+
// mem add <kind> --stdin --files f1,f2 [spec.md] ← read text from stdin (avoids shell metachar issues)
|
|
10
|
+
// ponytail: kind ผิด = แถวนั้นหายจาก view เงียบๆ — ตายตั้งแต่ตรงนี้ดีกว่า
|
|
11
|
+
if (!KINDS.includes(a[0] as WorkKind)) {
|
|
12
|
+
console.error(
|
|
13
|
+
`kind must be one of ${KINDS.join("|")} — got "${a[0] ?? ""}"`,
|
|
14
|
+
);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// hold บังคับ spec — ตรวจหลัง parse (spec อยู่ก่อน --files ได้)
|
|
18
|
+
// PLAN-convention-debt chunk 3: files[] เป็น required — optional field ที่วัดแล้ว
|
|
19
|
+
// fill rate = 0 (required+enum = 50/50) และ recall ของ log เก่าที่ไม่มี files[] จับได้แค่
|
|
20
|
+
// 48.1% — แก้ที่ขาเขียน ไม่ใช่ขาอ่าน: แถวไหนไม่บอกไฟล์ = ค้นไม่เจอตอนแตะไฟล์นั้น
|
|
21
|
+
const filesFlagIdx = a.indexOf("--files");
|
|
22
|
+
const filesVal = filesFlagIdx >= 0 ? a[filesFlagIdx + 1] : undefined;
|
|
23
|
+
if (!filesVal || filesVal.startsWith("--")) {
|
|
24
|
+
console.error(
|
|
25
|
+
`--files is required — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more] [spec.md]`,
|
|
26
|
+
);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const files = filesVal
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((s) => s.trim().replace(/^\.\//, ""))
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
if (files.length === 0) {
|
|
34
|
+
console.error(
|
|
35
|
+
`--files needs at least one path — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more]`,
|
|
36
|
+
);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const arg = a.slice(1);
|
|
40
|
+
const useStdin = arg.includes("--stdin");
|
|
41
|
+
const filtered = arg.filter(
|
|
42
|
+
(x) => x !== "--stdin" && x !== "--files" && x !== filesVal,
|
|
43
|
+
);
|
|
44
|
+
const spec = filtered.at(-1)?.endsWith(".md") ? filtered.pop() : undefined;
|
|
45
|
+
if (a[0] === "hold" && !spec) {
|
|
46
|
+
console.error(
|
|
47
|
+
`hold requires a spec — usage: ${memCmd} add hold "..." --files f1,f2 <spec.md>`,
|
|
48
|
+
);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
let text: string;
|
|
52
|
+
if (useStdin) {
|
|
53
|
+
text = (await Bun.stdin.text()).trim();
|
|
54
|
+
if (!text) {
|
|
55
|
+
console.error("stdin was empty — text is required");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// ต้องมีข้อความ
|
|
60
|
+
if (!filtered.length || (filtered.length === 0 && !spec)) {
|
|
61
|
+
console.error(
|
|
62
|
+
`text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
text = filtered.join(" ");
|
|
67
|
+
}
|
|
68
|
+
// read once — cap check + id collision
|
|
69
|
+
const all = rows();
|
|
70
|
+
// ponytail: เพดาน open next/hold กันสะสมไม่มีที่สิ้นสุด — บังคับ triage ของเก่าก่อนเปิดใหม่
|
|
71
|
+
const CAP = 15;
|
|
72
|
+
const CAP_HOLD = 10;
|
|
73
|
+
if (a[0] === "next" && !process.env.MEM_FORCE) {
|
|
74
|
+
const openNext = openRows(all).filter((r) => r.kind === "next").length;
|
|
75
|
+
if (openNext >= CAP) {
|
|
76
|
+
console.error(
|
|
77
|
+
`open next ${openNext}/${CAP} is full — close an old one first (or MEM_FORCE=1 if you really must)`,
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (a[0] === "hold" && !process.env.MEM_FORCE) {
|
|
83
|
+
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
84
|
+
if (openHold >= CAP_HOLD) {
|
|
85
|
+
console.error(
|
|
86
|
+
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first (or MEM_FORCE=1)`,
|
|
87
|
+
);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ponytail: กัน id ชน — logic รวมไว้ที่ nextId (store.ts)
|
|
92
|
+
const id = nextId(all);
|
|
93
|
+
put({ id, kind: a[0] as WorkKind, text, spec, files });
|
|
94
|
+
console.log(id);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const cmdClose = async (a: string[]) => {
|
|
98
|
+
// mem close <id> "<ทำอะไร / commit>" — tombstone ทำให้ claim void เอง
|
|
99
|
+
// mem close <id> --stdin ← read text from stdin
|
|
100
|
+
if (!a[0]) {
|
|
101
|
+
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (!rows().some((r) => "id" in r && r.id === a[0])) {
|
|
105
|
+
console.error(`no id "${a[0]}" in the log`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const rest = a.slice(1);
|
|
109
|
+
const useStdin = rest.includes("--stdin");
|
|
110
|
+
let text: string;
|
|
111
|
+
if (useStdin) {
|
|
112
|
+
text = (await Bun.stdin.text()).trim();
|
|
113
|
+
} else {
|
|
114
|
+
text = rest.join(" ");
|
|
115
|
+
}
|
|
116
|
+
put({ kind: "close", ref: a[0], text });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const cmdClaim = (a: string[]) => {
|
|
120
|
+
// mem claim <id> — ต้องมี id จริง, open, kind=next|bug, ไม่มี active claim ค้าง
|
|
121
|
+
if (!a[0]) {
|
|
122
|
+
console.error(`id is required — usage: ${memCmd} claim <id>`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
const all = rows();
|
|
126
|
+
const open = openRows(all);
|
|
127
|
+
const target = open.find((r) => r.id === a[0]);
|
|
128
|
+
if (!target) {
|
|
129
|
+
const exists = all.find((r) => "id" in r && r.id === a[0]);
|
|
130
|
+
console.error(
|
|
131
|
+
exists
|
|
132
|
+
? `id "${a[0]}" exists but is already closed (close tombstone)`
|
|
133
|
+
: `no id "${a[0]}" in the log`,
|
|
134
|
+
);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (target.kind !== "next" && target.kind !== "bug") {
|
|
138
|
+
console.error(
|
|
139
|
+
`only next/bug can be claimed — id "${a[0]}" is ${target.kind}`,
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const claims = claimsOf(all);
|
|
144
|
+
if (claims.has(a[0])) {
|
|
145
|
+
const c = claims.get(a[0])!;
|
|
146
|
+
console.error(
|
|
147
|
+
`id "${a[0]}" is already claimed by ${c.agent} — release it first`,
|
|
148
|
+
);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
put({ kind: "claim", ref: a[0] });
|
|
152
|
+
console.log(`claimed ${a[0]}`);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const cmdRelease = async (a: string[]) => {
|
|
156
|
+
// mem release <id> "<เหตุผล>?"
|
|
157
|
+
// mem release <id> --stdin ← read text from stdin
|
|
158
|
+
if (!a[0]) {
|
|
159
|
+
console.error(`id is required — usage: ${memCmd} release <id> [reason]`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
const all = rows();
|
|
163
|
+
const claims = claimsOf(all);
|
|
164
|
+
if (!claims.has(a[0])) {
|
|
165
|
+
console.error(`id "${a[0]}" has no active claim`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const rest = a.slice(1);
|
|
169
|
+
const useStdin = rest.includes("--stdin");
|
|
170
|
+
let text: string | undefined;
|
|
171
|
+
if (useStdin) {
|
|
172
|
+
const t = (await Bun.stdin.text()).trim();
|
|
173
|
+
text = t || undefined;
|
|
174
|
+
} else {
|
|
175
|
+
text = rest.join(" ") || undefined;
|
|
176
|
+
}
|
|
177
|
+
put({ kind: "release", ref: a[0], text });
|
|
178
|
+
console.log(`released ${a[0]}`);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const cmdSynced = (a: string[]) => {
|
|
182
|
+
// mem synced [path.md ...] — ประกาศว่า spec ตรงกับ log แล้ว (ไม่ระบุ = ทุก spec ที่ log อ้างถึง)
|
|
183
|
+
const specs = a.length
|
|
184
|
+
? a
|
|
185
|
+
: [
|
|
186
|
+
...new Set(
|
|
187
|
+
rows()
|
|
188
|
+
.filter((r) => r.kind === "synced" || ("spec" in r && r.spec))
|
|
189
|
+
.map((r) => ("spec" in r ? r.spec : undefined))
|
|
190
|
+
.filter(Boolean) as string[],
|
|
191
|
+
),
|
|
192
|
+
];
|
|
193
|
+
for (const spec of specs) put({ kind: "synced", spec });
|
|
194
|
+
console.log(`synced ${specs.length} spec`);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const cmdHook = async () => {
|
|
198
|
+
// PostToolUse: stdin = {tool_input:{file_path}} → แก้ spec ของ app นี้เสร็จ = synced ให้เอง
|
|
199
|
+
const j = (await Bun.stdin.json().catch(() => null)) as Record<
|
|
200
|
+
string,
|
|
201
|
+
unknown
|
|
202
|
+
> | null;
|
|
203
|
+
const f =
|
|
204
|
+
j && typeof j === "object" && "tool_input" in j
|
|
205
|
+
? ((j as { tool_input?: { file_path?: string } }).tool_input?.file_path ??
|
|
206
|
+
"")
|
|
207
|
+
: "";
|
|
208
|
+
const { app } = await import("../store.js");
|
|
209
|
+
const rel = f.startsWith(root) ? f.slice(root.length + 1) : f;
|
|
210
|
+
if (new RegExp(`^apps/${app}/(plan/(done/)?)?PLAN.*\\.md$`).test(rel)) {
|
|
211
|
+
put({ kind: "synced", spec: rel });
|
|
212
|
+
console.log(`synced ${rel}`);
|
|
213
|
+
}
|
|
214
|
+
};
|