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,360 @@
|
|
|
1
|
+
// commands/plan.ts — plan-sweep: หา PLAN-*.md ที่ header บอก shipped แล้วแต่ยังไม่ย้ายเข้า done/
|
|
2
|
+
// เหตุผล: ย้ายมือ = ต้องไล่แก้ relative link เอง (ในไฟล์ + ไฟล์อื่นที่ลิงก์มา) → ข้ามขั้นตอนบ่อย
|
|
3
|
+
// ไม่มี arg = report เฉยๆ (ปลอดภัย โชว์ทุก kickoff/stale run ได้)
|
|
4
|
+
// <file.md> = เช็คไฟล์เดียวว่าพร้อมย้ายไหม
|
|
5
|
+
// <file.md> --apply = git mv + แก้ markdown link ในไฟล์เอง + แก้ inbound link จากไฟล์อื่นใน plan/
|
|
6
|
+
// + แจ้ง warning plain-text mention (detect-only, ไม่ auto-fix)
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { openRows } from "../selectors.js";
|
|
18
|
+
import { doneDir, memCmd, nextId, planDir, put, rel, rows } from "../store.js";
|
|
19
|
+
|
|
20
|
+
const SHIPPED = /^>\s*✅/m;
|
|
21
|
+
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
22
|
+
const HELD = /^status:\s*(blocked|superseded)\b/m;
|
|
23
|
+
|
|
24
|
+
// header ✅ shipped ไม่ได้อยู่บรรทัดแรกอีกแล้ว — plan format ปัจจุบันขึ้นต้นด้วย frontmatter
|
|
25
|
+
// แล้วตามด้วย `# title` (ดู templates/PLAN.md) เช็กหัวไฟล์แทนที่จะเช็กบรรทัดแรกบรรทัดเดียว
|
|
26
|
+
//
|
|
27
|
+
// frontmatter ชนะ header ✅ เสมอ: plan ที่ ship ไปบาง chunk แล้วติดรอของข้างนอก (VPS, คนใช้,
|
|
28
|
+
// การตัดสินใจ) เขียน `status: blocked` ไว้ = ตั้งใจให้อยู่ใน plan/ ต่อ ไม่ใช่ของที่ลืมย้าย
|
|
29
|
+
// ถ้าไม่ดูตรงนี้ plan แบบนั้นจะขึ้น "shipped but never archived" ทุกครั้งไปตลอด แล้วคนก็เลิกอ่าน
|
|
30
|
+
// รายการนี้ทั้งรายการ — ซึ่งเป็นอาการเดียวกับที่ทำให้ done/ ไม่เคยขยับตั้งแต่แรก
|
|
31
|
+
export const hasShippedHeader = (file: string): boolean => {
|
|
32
|
+
const head = readFileSync(file, "utf8").slice(0, 2048);
|
|
33
|
+
if (!SHIPPED.test(head)) return false;
|
|
34
|
+
return !HELD.test(FRONT.exec(head)?.[1] ?? "");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const planSweepCmd = `${memCmd} plan-sweep`;
|
|
38
|
+
|
|
39
|
+
const mdFiles = (dir: string): string[] =>
|
|
40
|
+
existsSync(dir)
|
|
41
|
+
? readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
42
|
+
e.isDirectory()
|
|
43
|
+
? mdFiles(join(dir, e.name))
|
|
44
|
+
: e.name.endsWith(".md")
|
|
45
|
+
? [join(dir, e.name)]
|
|
46
|
+
: [],
|
|
47
|
+
)
|
|
48
|
+
: [];
|
|
49
|
+
|
|
50
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
|
|
52
|
+
// ไฟล์ย้าย dir แล้ว (เนื้อหาเดิม) — markdown link เดิมหมายถึง path เดิมเมื่ออิง oldDir, ต้อง re-relativize ผ่าน newDir
|
|
53
|
+
// resolve จาก newDir (ที่ไฟล์อยู่ตอนนี้) — ถ้า target ก็ย้ายมา same dir → ได้ชื่อไฟล์ล้วน,
|
|
54
|
+
// ถ้า target ยังอยู่ oldDir → ได้ ../target (ถูกทั้งสองทาง)
|
|
55
|
+
// pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
|
|
56
|
+
export const rewriteMovedFileLinks = (
|
|
57
|
+
file: string,
|
|
58
|
+
oldDir: string,
|
|
59
|
+
newDir: string,
|
|
60
|
+
): number => {
|
|
61
|
+
const src = readFileSync(file, "utf8");
|
|
62
|
+
let n = 0;
|
|
63
|
+
|
|
64
|
+
const out = src.replace(/\]\(([^)]+)\)/g, (m, t: string) => {
|
|
65
|
+
const [target, anchor] = t.split("#");
|
|
66
|
+
if (!target || /^(https?:|mailto:|\/)/.test(target)) return m;
|
|
67
|
+
// resolve from where the file IS now (newDir); if target doesn't exist
|
|
68
|
+
// there, fall back to oldDir (target stayed in original location)
|
|
69
|
+
const inNew = resolve(newDir, target);
|
|
70
|
+
const abs = existsSync(inNew) ? inNew : resolve(oldDir, target);
|
|
71
|
+
n++;
|
|
72
|
+
return `](${relative(newDir, abs) || "."}${anchor ? `#${anchor}` : ""})`;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (n) writeFileSync(file, out);
|
|
76
|
+
return n;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ไฟล์อื่นที่ลิงก์ชี้มาที่ path เดิม (oldAbs) → แก้ให้ชี้ path ใหม่ (newAbs) แทน
|
|
80
|
+
// pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
|
|
81
|
+
export const rewriteMarkdownLinks = (
|
|
82
|
+
file: string,
|
|
83
|
+
oldAbs: string,
|
|
84
|
+
newAbs: string,
|
|
85
|
+
): number => {
|
|
86
|
+
const src = readFileSync(file, "utf8");
|
|
87
|
+
let n = 0;
|
|
88
|
+
const newRel = relative(dirname(file), newAbs) || ".";
|
|
89
|
+
|
|
90
|
+
const out = src.replace(/\]\(([^)]+)\)/g, (m, t: string) => {
|
|
91
|
+
const [target, anchor] = t.split("#");
|
|
92
|
+
if (!target || /^(https?:|mailto:|\/)/.test(target)) return m;
|
|
93
|
+
if (resolve(dirname(file), target) !== oldAbs) return m;
|
|
94
|
+
n++;
|
|
95
|
+
return `](${newRel}${anchor ? `#${anchor}` : ""})`;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (n) writeFileSync(file, out);
|
|
99
|
+
return n;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// นับ plain-text mention ของ target filename ในไฟล์ (detect-only, ไม่เขียนไฟล์)
|
|
103
|
+
// regex: ไม่ใช่ markdown link [text](url) — จับทั้งแบบมี/ไม่มี .md extension
|
|
104
|
+
// ครอบคลุม: prose, backtick code span, code fence — ทุก context ที่ไม่ใช่ markdown link
|
|
105
|
+
//
|
|
106
|
+
// Fix (2026-09-02): ตัด markdown link ทั้งก้อน [text](target) ออกก่อน (ทั้ง display text และ
|
|
107
|
+
// target — ไม่ใช่แค่ target) แล้วค่อยรัน plain-text regex บน string ที่เหลือ โดยไม่ต้องพึ่ง
|
|
108
|
+
// lookbehind แล้ว — lookbehind เดิม `(?<![/\[(])` ตั้งใจกัน false-positive จาก markdown link
|
|
109
|
+
// แต่ผลข้างเคียงคือ exclude ทุก mention ที่มี `/` นำหน้าไปด้วย (เช่น `done/PLAN-x.md` หรือ
|
|
110
|
+
// `apps/vela/plan/PLAN-x.md`) — false-negative ตัวจริงที่พลาดใน 6e411042
|
|
111
|
+
// Fix รอบแรก (ตัดแค่ `](target)`) พลาด — เหลือ `[display-text]` ไว้ไม่ได้ตัด ทำให้ link ที่ถูกต้อง
|
|
112
|
+
// อยู่แล้วอย่าง `[PLAN-x.md](../done/PLAN-x.md)` (pattern จริงในไฟล์นี้ทั้งหมด) โดน count ซ้ำเป็น
|
|
113
|
+
// plain-text mention — ต้องตัดทั้งก้อน [..](..) ไม่ใช่แค่ส่วน (..)
|
|
114
|
+
export const countPlainTextMentions = (
|
|
115
|
+
file: string,
|
|
116
|
+
target: string,
|
|
117
|
+
): number => {
|
|
118
|
+
const src = readFileSync(file, "utf8");
|
|
119
|
+
const baseName = target.replace(/\.md$/, "");
|
|
120
|
+
const withoutLinks = src.replace(/\[[^\]]*\]\([^)]+\)/g, "");
|
|
121
|
+
const plainRe = new RegExp(
|
|
122
|
+
`\\b${escapeRe(baseName)}(?:\\.md)?\\b(?!\\.\\w)`,
|
|
123
|
+
"g",
|
|
124
|
+
);
|
|
125
|
+
let count = 0;
|
|
126
|
+
|
|
127
|
+
while (plainRe.test(withoutLinks)) count++;
|
|
128
|
+
return count;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ใช้ร่วมกับ dashboard (now/kickoff) — ไฟล์ plan/ ที่มี header shipped แต่ยังไม่ย้ายเข้า done/
|
|
132
|
+
export const shippedNotMoved = (): string[] => {
|
|
133
|
+
const dir = planDir;
|
|
134
|
+
if (!existsSync(dir)) return [];
|
|
135
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
136
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
137
|
+
.map((e) => e.name)
|
|
138
|
+
.filter((name) => hasShippedHeader(join(dir, name)));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const cmdPlanSweep = (a: string[]) => {
|
|
142
|
+
const dir = planDir;
|
|
143
|
+
if (!existsSync(dir)) {
|
|
144
|
+
console.log(`no ${rel(dir)}/ — nothing to sweep`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const candidates = shippedNotMoved();
|
|
148
|
+
|
|
149
|
+
const target = a.find((x) => x.endsWith(".md"));
|
|
150
|
+
const apply = a.includes("--apply");
|
|
151
|
+
|
|
152
|
+
if (!target) {
|
|
153
|
+
if (!candidates.length) {
|
|
154
|
+
console.log(
|
|
155
|
+
`no PLAN with a ✅ shipped header is sitting outside ${rel(doneDir)}/`,
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const all = rows();
|
|
160
|
+
console.log(
|
|
161
|
+
`# plan-sweep — ${candidates.length} file(s) marked shipped but not archived\n`,
|
|
162
|
+
);
|
|
163
|
+
for (const name of candidates) {
|
|
164
|
+
const spec = `${rel(dir)}/${name}`;
|
|
165
|
+
const openN = openRows(all).filter((r) => r.spec === spec).length;
|
|
166
|
+
const warn = openN
|
|
167
|
+
? ` ⚠ ${openN} open row(s) (next/bug/hold/decision/note) — check before moving`
|
|
168
|
+
: "";
|
|
169
|
+
console.log(`- ${rel(dir)}/${name}${warn}`);
|
|
170
|
+
}
|
|
171
|
+
console.log(`\nmove: ${planSweepCmd} <file.md> --apply`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const src = join(dir, target);
|
|
176
|
+
if (!existsSync(src)) {
|
|
177
|
+
console.error(`${rel(src)} not found`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
const shipped = hasShippedHeader(src);
|
|
181
|
+
if (!apply) {
|
|
182
|
+
console.log(
|
|
183
|
+
shipped
|
|
184
|
+
? `${target}: has a ✅ shipped header — ready to move (add --apply)`
|
|
185
|
+
: `${target}: no ✅ shipped header at the top — check the whole file is actually done`,
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ponytail: --apply เดิม (ก่อนหน้านี้) ไม่บังคับ 2 เงื่อนไขนี้เลย — ผ่าน dry-run message ได้ก็จริง
|
|
191
|
+
// แต่รัน --apply ตรง ๆ ข้ามได้หมด → เสี่ยงเวลา agent ship เองอัตโนมัติไม่มีคนเช็ค แก้เป็น hard block
|
|
192
|
+
if (!shipped && !process.env.MEM_FORCE) {
|
|
193
|
+
console.error(
|
|
194
|
+
`${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
|
|
195
|
+
);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const openSpec = `${rel(dir)}/${target}`;
|
|
199
|
+
const openN = openRows(rows()).filter((r) => r.spec === openSpec);
|
|
200
|
+
if (openN.length && !process.env.MEM_FORCE) {
|
|
201
|
+
console.error(
|
|
202
|
+
`${target}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
|
|
203
|
+
openN.map((r) => ` [${r.id}] ${r.kind} ${r.text}`).join("\n"),
|
|
204
|
+
);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const dst = join(doneDir, target);
|
|
209
|
+
if (existsSync(dst)) {
|
|
210
|
+
console.error(`${rel(dst)} already exists`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ponytail: ไฟล์ที่เพิ่งเขียนในรอบนี้อาจยังไม่ git add — `git mv` fail แบบเงียบ (exit 128, ไม่ throw)
|
|
215
|
+
// แล้วโค้ดต่อไปพัง ENOENT ตอนอ่าน dst ที่ไม่มีจริง — stage ก่อนเสมอ (no-op ถ้า track อยู่แล้ว)
|
|
216
|
+
mkdirSync(doneDir, { recursive: true });
|
|
217
|
+
Bun.spawnSync(["git", "add", src]);
|
|
218
|
+
const mv = Bun.spawnSync(["git", "mv", src, dst]);
|
|
219
|
+
if (mv.exitCode !== 0) {
|
|
220
|
+
console.error(`git mv failed (${mv.stderr.toString().trim()}) — not moved`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// done/ เป็นพี่น้องกับ plan/ = ลึกเท่าเดิม ลิงก์ในไฟล์ยังชี้ถูกทุกเส้น ไม่ต้องแตะ
|
|
225
|
+
// layout เก่า (plan/done/) ลึกขึ้น 1 ชั้น ถึงจะต้อง re-relativize
|
|
226
|
+
const nested = dirname(doneDir) !== dirname(dir);
|
|
227
|
+
const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
|
|
228
|
+
|
|
229
|
+
let inbound = 0;
|
|
230
|
+
let inboundFiles = 0;
|
|
231
|
+
for (const f of mdFiles(dir)) {
|
|
232
|
+
if (f === dst) continue;
|
|
233
|
+
const n = rewriteMarkdownLinks(f, src, dst);
|
|
234
|
+
if (n) {
|
|
235
|
+
inbound += n;
|
|
236
|
+
inboundFiles++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(`moved ${rel(src)} → ${rel(dst)}`);
|
|
241
|
+
console.log(
|
|
242
|
+
nested
|
|
243
|
+
? `links rewritten inside the file: ${ownLinks}`
|
|
244
|
+
: `links rewritten inside the file: 0 (same depth, existing links still resolve)`,
|
|
245
|
+
);
|
|
246
|
+
console.log(
|
|
247
|
+
`inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// log decision — record ship event (reuse existing kind, ไม่ต้อง schema ใหม่)
|
|
251
|
+
const doneSpec = `${rel(dst)}`;
|
|
252
|
+
put({
|
|
253
|
+
id: nextId(rows()),
|
|
254
|
+
kind: "decision",
|
|
255
|
+
text: `${target} shipped → ${rel(dst)}`,
|
|
256
|
+
spec: doneSpec,
|
|
257
|
+
});
|
|
258
|
+
// ponytail: decision นี้ *คือ* การย้ายเอง ไม่มีอะไรต้องเขียนกลับเข้า spec อีก — ไม่ mark synced
|
|
259
|
+
// ทันที staleReport จะขึ้น "decision ยังไม่เข้า spec" ทุกครั้งที่ ship (เห็นจริงใน kickoff 2026-09-02)
|
|
260
|
+
put({ kind: "synced", spec: doneSpec });
|
|
261
|
+
|
|
262
|
+
// plain-text mention detection (detect-only, ไม่ auto-fix)
|
|
263
|
+
let plainTextTotal = 0;
|
|
264
|
+
const plainTextFiles: string[] = [];
|
|
265
|
+
for (const f of mdFiles(dir)) {
|
|
266
|
+
const n = countPlainTextMentions(f, target);
|
|
267
|
+
if (n) {
|
|
268
|
+
plainTextTotal += n;
|
|
269
|
+
plainTextFiles.push(f.replace(`${dir}/`, ""));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (plainTextTotal > 0) {
|
|
273
|
+
console.log(
|
|
274
|
+
`⚠ ${plainTextTotal} plain-text mention(s) in ${plainTextFiles.length} file(s) under ${rel(dir)}/ — grep and update the paths yourself:\n${plainTextFiles.join("\n")}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ไฟล์นอก plan/ ที่ mention target — detect-only
|
|
279
|
+
const grep = Bun.spawnSync([
|
|
280
|
+
"git",
|
|
281
|
+
"grep",
|
|
282
|
+
"-l",
|
|
283
|
+
target,
|
|
284
|
+
"--",
|
|
285
|
+
// ขอบเขต "ไฟล์นอก plan/" = โฟลเดอร์ที่ plan/ อยู่ใต้มัน (apps/vela, .fapony, …)
|
|
286
|
+
rel(dirname(planDir)),
|
|
287
|
+
`:!${rel(dir)}`,
|
|
288
|
+
])
|
|
289
|
+
.stdout.toString()
|
|
290
|
+
.trim();
|
|
291
|
+
if (grep)
|
|
292
|
+
console.log(
|
|
293
|
+
`⚠ files outside ${rel(dir)}/ still mention "${target}" — check them yourself (not auto-fixed):\n${grep}`,
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// plan-check — list active PLANs + detect shipped-not-moved + broken links
|
|
298
|
+
// exit 0 = clean, 1 = issues found
|
|
299
|
+
export const cmdPlanCheck = (a: string[]) => {
|
|
300
|
+
const quiet = a.includes("--quiet");
|
|
301
|
+
const dir = planDir;
|
|
302
|
+
if (!existsSync(dir)) {
|
|
303
|
+
if (!quiet) console.log(`no ${rel(dir)}/ — skip`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const issues: string[] = [];
|
|
308
|
+
|
|
309
|
+
// 1) List active PLANs
|
|
310
|
+
const active = mdFiles(dir).filter((f) => !f.includes("/done/"));
|
|
311
|
+
if (!quiet) {
|
|
312
|
+
console.log(`${rel(dir)}/ — ${active.length} file(s) (active)\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 2) Shipped-not-moved check
|
|
316
|
+
const shipped = shippedNotMoved();
|
|
317
|
+
for (const name of shipped) {
|
|
318
|
+
issues.push(
|
|
319
|
+
`${name} — has a shipped header but was never archived\n fix: ${planSweepCmd} ${name} --apply`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 3) Broken link check — resolve [text](target) where target is in plan/**
|
|
324
|
+
// only check active files (not done/) — done/ files are historical snapshots with external refs
|
|
325
|
+
const linkRe = /\]\(([^)]+)\)/g;
|
|
326
|
+
for (const f of active) {
|
|
327
|
+
// ตัด fenced block + inline code ทิ้งก่อน (แทนที่ด้วย spaces เพื่อคง line offset) —
|
|
328
|
+
// ตัวอย่าง link ใน code (เช่น spec ของเครื่องมือนี้เอง) ต้องไม่ถูกนับเป็น link จริง
|
|
329
|
+
const src = readFileSync(f, "utf8")
|
|
330
|
+
.replace(/```[\s\S]*?```/g, (b) => b.replace(/[^\n]/g, " "))
|
|
331
|
+
.replace(/`[^`\n]*`/g, (b) => " ".repeat(b.length));
|
|
332
|
+
let m: RegExpExecArray | null;
|
|
333
|
+
while ((m = linkRe.exec(src)) !== null) {
|
|
334
|
+
const target = m[1];
|
|
335
|
+
if (!target || /^(https?:|mailto:|\/)/.test(target)) continue;
|
|
336
|
+
const [pathPart] = target.split("#");
|
|
337
|
+
if (!pathPart) continue;
|
|
338
|
+
const resolved = resolve(dirname(f), pathPart);
|
|
339
|
+
if (!resolved.startsWith(dir)) continue; // only check links within plan/
|
|
340
|
+
if (!existsSync(resolved)) {
|
|
341
|
+
const rel = f.replace(`${dir}/`, "");
|
|
342
|
+
const line = src.slice(0, m.index).split("\n").length;
|
|
343
|
+
issues.push(
|
|
344
|
+
`${rel}:${line} — broken link → ${target} (no such file in plan/)\n fix: correct the path or create the file it points at`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (issues.length === 0) {
|
|
351
|
+
if (!quiet) console.log("✅ clean");
|
|
352
|
+
process.exit(0);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
console.error(`🚨 ${issues.length} issue(s):\n`);
|
|
356
|
+
for (let i = 0; i < issues.length; i++) {
|
|
357
|
+
console.error(`${i + 1}. ${issues[i]}\n`);
|
|
358
|
+
}
|
|
359
|
+
process.exit(1);
|
|
360
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// commands/read.ts — read-only commands: now (default), done, stale, find, kickoff
|
|
2
|
+
|
|
3
|
+
import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
|
|
4
|
+
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
5
|
+
import type { CloseRow, WorkRow } from "../store.js";
|
|
6
|
+
import { app, memCmd, rows } from "../store.js";
|
|
7
|
+
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
8
|
+
import { THRESHOLD } from "./rotate.js";
|
|
9
|
+
|
|
10
|
+
const rotateLine = (n: number) =>
|
|
11
|
+
n >= THRESHOLD
|
|
12
|
+
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
13
|
+
: "";
|
|
14
|
+
|
|
15
|
+
const planSweepLine = () => {
|
|
16
|
+
const pending = shippedNotMoved();
|
|
17
|
+
return pending.length
|
|
18
|
+
? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
|
|
19
|
+
pending.map((n) => `- ${n}`).join("\n") +
|
|
20
|
+
`\n(move: ${planSweepCmd} <file.md> --apply)`
|
|
21
|
+
: "";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const cmdNow = () => {
|
|
25
|
+
// mem now (default) — next+bug+hold. decision/note ไม่ใช่งานค้าง → ค้นด้วย find แทน
|
|
26
|
+
const all = rows();
|
|
27
|
+
console.log(`# ${app} — ${all.length} entries`);
|
|
28
|
+
printOpenRows(all, { showHold: true });
|
|
29
|
+
const decisionN = openRows(all).filter((r) => r.kind === "decision").length;
|
|
30
|
+
const noteN = openRows(all).filter((r) => r.kind === "note").length;
|
|
31
|
+
console.log(
|
|
32
|
+
`\n## decision ${decisionN} · note ${noteN} — search: ${memCmd} find <word>`,
|
|
33
|
+
);
|
|
34
|
+
const stale = staleReport(all);
|
|
35
|
+
if (stale.length)
|
|
36
|
+
console.log(
|
|
37
|
+
`\n## ⚠ stale (${stale.length})\n` +
|
|
38
|
+
stale.map((l) => `- ${l}`).join("\n"),
|
|
39
|
+
);
|
|
40
|
+
const sweep = planSweepLine();
|
|
41
|
+
if (sweep) console.log(sweep);
|
|
42
|
+
const rotate = rotateLine(all.length);
|
|
43
|
+
if (rotate) console.log(rotate);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const cmdDone = () => {
|
|
47
|
+
const all = rows();
|
|
48
|
+
for (const l of doneLines(all)) console.log(l);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const cmdStale = () => {
|
|
52
|
+
for (const l of staleReport(rows())) console.log(l);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const cmdFind = (a: string[]) => {
|
|
56
|
+
// mem find <คำ> — grep text/spec ไม่สนตัวพิมพ์เล็กใหญ่, ล่าสุดก่อน, จำกัด 20 แถว
|
|
57
|
+
const q = a.join(" ").toLowerCase();
|
|
58
|
+
if (!q) {
|
|
59
|
+
console.error(`usage: ${memCmd} find <word>`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const hits = rows()
|
|
63
|
+
.filter(
|
|
64
|
+
(r): r is WorkRow =>
|
|
65
|
+
r.kind !== "close" &&
|
|
66
|
+
r.kind !== "synced" &&
|
|
67
|
+
r.kind !== "claim" &&
|
|
68
|
+
r.kind !== "release",
|
|
69
|
+
)
|
|
70
|
+
.filter(
|
|
71
|
+
(r) =>
|
|
72
|
+
r.text.toLowerCase().includes(q) ||
|
|
73
|
+
(r.spec ?? "").toLowerCase().includes(q),
|
|
74
|
+
)
|
|
75
|
+
.slice(-20);
|
|
76
|
+
for (const r of hits)
|
|
77
|
+
console.log(
|
|
78
|
+
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
79
|
+
);
|
|
80
|
+
if (!hits.length) console.log("(no matches)");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const cmdKickoff = (a: string[]) => {
|
|
84
|
+
// mem kickoff [id|spec.md]
|
|
85
|
+
const all = rows();
|
|
86
|
+
const arg = a[0] ?? "";
|
|
87
|
+
|
|
88
|
+
if (!arg) {
|
|
89
|
+
// ไม่มี args = now + section "ล่าสุด" = closes 10 รายการล่าสุด
|
|
90
|
+
console.log(`# ${app} — ${all.length} entries`);
|
|
91
|
+
printOpenRows(all, { showHold: true });
|
|
92
|
+
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
93
|
+
const stale = staleReport(all);
|
|
94
|
+
if (stale.length)
|
|
95
|
+
console.log(
|
|
96
|
+
`\n## ⚠ stale (${stale.length})\n` +
|
|
97
|
+
stale.map((l) => `- ${l}`).join("\n"),
|
|
98
|
+
);
|
|
99
|
+
const sweep = planSweepLine();
|
|
100
|
+
if (sweep) console.log(sweep);
|
|
101
|
+
const rotate = rotateLine(all.length);
|
|
102
|
+
if (rotate) console.log(rotate);
|
|
103
|
+
} else if (arg.endsWith(".md")) {
|
|
104
|
+
// spec.md = brief ของ spec นั้น
|
|
105
|
+
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
106
|
+
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
107
|
+
const open = openRows(all);
|
|
108
|
+
const specRows = open.filter((r) => r.spec === arg);
|
|
109
|
+
const specDecisions = all
|
|
110
|
+
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === arg)
|
|
111
|
+
.slice(-5);
|
|
112
|
+
const specCloses = all
|
|
113
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
114
|
+
.filter((r) => byId.get(r.ref)?.spec === arg)
|
|
115
|
+
.slice(-3);
|
|
116
|
+
|
|
117
|
+
console.log(`# ${arg} — ${specRows.length} open rows`);
|
|
118
|
+
if (specRows.length) {
|
|
119
|
+
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
120
|
+
}
|
|
121
|
+
if (specDecisions.length) {
|
|
122
|
+
console.log(
|
|
123
|
+
`\n## decisions\n` +
|
|
124
|
+
specDecisions
|
|
125
|
+
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
126
|
+
.join("\n"),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (specCloses.length) {
|
|
130
|
+
console.log(
|
|
131
|
+
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
135
|
+
console.log("(no entries for this spec)");
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// id = brief ของงานนั้น
|
|
139
|
+
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
140
|
+
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
141
|
+
const target = byId.get(arg);
|
|
142
|
+
if (!target) {
|
|
143
|
+
console.error(`no id "${arg}" in the log`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
const claims = claimsOf(all);
|
|
147
|
+
const claimInfo = claims.has(arg)
|
|
148
|
+
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
149
|
+
: "";
|
|
150
|
+
|
|
151
|
+
const spec = target.spec;
|
|
152
|
+
const open = spec
|
|
153
|
+
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
154
|
+
: [];
|
|
155
|
+
const specDecisions = spec
|
|
156
|
+
? all
|
|
157
|
+
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
158
|
+
.slice(-5)
|
|
159
|
+
: [];
|
|
160
|
+
const specNotes = spec
|
|
161
|
+
? all
|
|
162
|
+
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
163
|
+
.slice(-5)
|
|
164
|
+
: [];
|
|
165
|
+
const specCloses = spec
|
|
166
|
+
? all
|
|
167
|
+
.filter((r): r is CloseRow => r.kind === "close")
|
|
168
|
+
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
169
|
+
.slice(-3)
|
|
170
|
+
: [];
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
174
|
+
);
|
|
175
|
+
if (open.length) {
|
|
176
|
+
console.log(
|
|
177
|
+
`\n## open (same spec)\n` +
|
|
178
|
+
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (specDecisions.length || specNotes.length) {
|
|
182
|
+
console.log(`\n## decisions & notes (spec)\n`);
|
|
183
|
+
for (const r of [...specDecisions, ...specNotes]) {
|
|
184
|
+
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (specCloses.length) {
|
|
188
|
+
console.log(
|
|
189
|
+
`\n## closes (spec)\n` +
|
|
190
|
+
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
|
|
2
|
+
// เก็บ open work rows + active claim ไว้ ที่เหลือ (close/release/synced ของ ref ที่ปิดแล้ว)
|
|
3
|
+
// git mv ไปไฟล์ archive แยก (ไม่ลบ) — ประวัติเก่ายังอยู่ ค้นย้อนหลังได้ผ่าน git log/git show
|
|
4
|
+
|
|
5
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { rotateKeep } from "../selectors.js";
|
|
8
|
+
import { appendRaw, dir, LOG, rows } from "../store.js";
|
|
9
|
+
|
|
10
|
+
// ponytail: threshold = จำนวนแถวทั้งหมด ไม่ใช่แค่ open — ที่กลัวคือไฟล์บวม/grep ช้าตอนหลายคนใช้พร้อมกัน
|
|
11
|
+
// (view อ่านไม่รู้เรื่องเป็นปัญหาคนละอันที่ CAP ใน write.ts จัดการอยู่แล้ว)
|
|
12
|
+
// 3000 กะจาก solo 1 สัปดาห์ = ~2k แถว — ปรับได้ด้วย MEM_ROTATE_THRESHOLD ถ้า pace ต่างจากนี้มาก
|
|
13
|
+
export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
|
|
14
|
+
|
|
15
|
+
export const cmdRotate = (a: string[]) => {
|
|
16
|
+
const apply = a.includes("--apply");
|
|
17
|
+
const all = rows();
|
|
18
|
+
const over = all.length >= THRESHOLD;
|
|
19
|
+
|
|
20
|
+
if (!apply) {
|
|
21
|
+
console.log(
|
|
22
|
+
`${all.length} rows (threshold ${THRESHOLD})${over ? " — over the threshold, run --apply" : " — under the threshold"}`,
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!over && !process.env.MEM_FORCE) {
|
|
27
|
+
console.log(
|
|
28
|
+
`${all.length}/${THRESHOLD} rows — under the threshold, no rotate needed (MEM_FORCE=1 to do it anyway)`,
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keep = rotateKeep(all);
|
|
34
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
35
|
+
const archived = join(dir, `log.${stamp}.jsonl`);
|
|
36
|
+
if (existsSync(archived)) {
|
|
37
|
+
console.error(
|
|
38
|
+
`${archived} already exists (rotated today?) — remove or move it first to run again`,
|
|
39
|
+
);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// เหมือน plan-sweep: stage ก่อนกัน git mv fail เงียบถ้าไฟล์ยังไม่ track (exit 128)
|
|
44
|
+
Bun.spawnSync(["git", "add", LOG]);
|
|
45
|
+
const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
|
|
46
|
+
if (mv.exitCode !== 0) {
|
|
47
|
+
console.error(
|
|
48
|
+
`git mv failed (${mv.stderr.toString().trim()}) — rotate aborted`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
writeFileSync(LOG, "");
|
|
54
|
+
for (const r of keep) appendRaw(LOG, r);
|
|
55
|
+
|
|
56
|
+
console.log(
|
|
57
|
+
`rotated: ${all.length} rows → archive ${archived.replace(`${dir}/`, "")} (git history intact), ${keep.length} row(s) left in log.jsonl (open + active claims)`,
|
|
58
|
+
);
|
|
59
|
+
};
|