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
package/src/plan-seed.ts
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// src/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
|
|
2
|
+
//
|
|
3
|
+
// Writes PLAN + SPEC straight into planDir/specDir. What it pre-fills is the
|
|
4
|
+
// structure (frontmatter, the 8 sections, prior art, ledger context) — the
|
|
5
|
+
// agent is left with judgment only.
|
|
6
|
+
//
|
|
7
|
+
// **§2/§5 no longer carry seeded facts (2026-09-18).** They held a repetition
|
|
8
|
+
// scan and analyze findings; measured across every plan that ever used them,
|
|
9
|
+
// §5 printed "no findings" 3 times out of 3 and §2 printed a naming
|
|
10
|
+
// observation nobody cited. The cause is structural: `--scope` narrows to the
|
|
11
|
+
// files about to change, while both producers report whole-repo properties
|
|
12
|
+
// (hub/orphan/cycle across directories) — empty when scoped, noisy when not.
|
|
13
|
+
// Worse, a judgment heading pre-filled with a shrug teaches the reader that
|
|
14
|
+
// every seeded line is noise. Facts now come from `fapony analyze <dir>` and
|
|
15
|
+
// `review-seed --files` run at draft time on the real scope, where they do not
|
|
16
|
+
// go stale. Do not re-add a producer here without measuring that its output is
|
|
17
|
+
// cited in a shipped plan.
|
|
18
|
+
//
|
|
19
|
+
// SPEC chunks are hard-capped review-seed style — PLAN ≤ ~60 and SPEC ≤ 200
|
|
20
|
+
// lines are the contract (PLAN-seed-scope-and-cap §3, measured against an
|
|
21
|
+
// 18,175-line SPEC innominix deleted by hand).
|
|
22
|
+
// init-family: writes only the files the user asked for, inside planDir/
|
|
23
|
+
// specDir — never runtime state (that stays in ~/.config/fapony/).
|
|
24
|
+
// Deterministic: same input, same output, no LLM call.
|
|
25
|
+
//
|
|
26
|
+
// Composes existing producers — no new parsing, no new table, no MCP tool.
|
|
27
|
+
|
|
28
|
+
import { execSync } from "node:child_process";
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
38
|
+
import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "./analyze.js";
|
|
39
|
+
import { computeModelFit } from "./context/projectHealth.js";
|
|
40
|
+
import { doneDir, planDir, specDir } from "./db/getters.js";
|
|
41
|
+
import { loadConfig } from "./db/load.js";
|
|
42
|
+
import type { Config } from "./db/types.js";
|
|
43
|
+
import { extractExports } from "./map.js";
|
|
44
|
+
import { readRecentMemDecisions } from "./memory.js";
|
|
45
|
+
import { getStatsData } from "./stats/data.js";
|
|
46
|
+
|
|
47
|
+
// One chunk = one module's signatures — past ~40 lines a module is its own
|
|
48
|
+
// reading task, and the whole-SPEC cap below does the final trim.
|
|
49
|
+
const MAX_CHUNK_LINES = 40;
|
|
50
|
+
// The whole-SPEC contract (§3.1): whatever the scope, the file stays ≤ 200.
|
|
51
|
+
const MAX_SPEC_LINES = 200;
|
|
52
|
+
// Above this many files in scope the caps start eating output silently —
|
|
53
|
+
// warn so the shortness is explained. (guess — first cutoff that felt right)
|
|
54
|
+
const SCOPE_WARN_FILES = 300;
|
|
55
|
+
// Shipped plans/specs that already touched this scope. Capped low on purpose:
|
|
56
|
+
// this is a "go read that first" pointer, not a bibliography.
|
|
57
|
+
const MAX_PRIOR_ART = 5;
|
|
58
|
+
const SIG_MAX = 90;
|
|
59
|
+
// Anchor-safe slug: lowercase, non-alphanumerics → dash.
|
|
60
|
+
const slug = (s: string): string =>
|
|
61
|
+
s
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
64
|
+
.replace(/^-|-$/g, "");
|
|
65
|
+
|
|
66
|
+
// Absolute source files under a scope root. A single-file scope counts as
|
|
67
|
+
// itself — collectSourceFiles only walks dirs, so a file root would vanish.
|
|
68
|
+
function scopeSourceFiles(root: string): string[] {
|
|
69
|
+
let st: import("node:fs").Stats;
|
|
70
|
+
try {
|
|
71
|
+
st = statSync(root);
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
if (!st.isDirectory()) {
|
|
76
|
+
return isSourceFile(st, basename(root)) ? [root] : [];
|
|
77
|
+
}
|
|
78
|
+
return collectSourceFiles(root, { skipHidden: true }).map((rel) =>
|
|
79
|
+
join(root, rel),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The overwrite check catches a filename collision. It does not catch the
|
|
84
|
+
// expensive mistake: planning again what a shipped plan already decided —
|
|
85
|
+
// PLAN-cost-per-pass froze the tokens/pass formula, and a later seed over
|
|
86
|
+
// src/stats gave no hint it existed. Scope paths are the join key: a shipped
|
|
87
|
+
// plan that names this directory decided something about the code this seed
|
|
88
|
+
// is about to plan. Headers only (title + shipped date) — pulling the bodies
|
|
89
|
+
// in would recreate the reading task the plan exists to avoid.
|
|
90
|
+
function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
|
|
91
|
+
const placeholder = "- _(agent เติม)_";
|
|
92
|
+
const keys = roots
|
|
93
|
+
.map((r) => relative(cwd, r))
|
|
94
|
+
.filter((r) => r !== "" && r !== ".");
|
|
95
|
+
// No --scope means every shipped plan matches — a list of everything points
|
|
96
|
+
// at nothing.
|
|
97
|
+
if (keys.length === 0) return placeholder;
|
|
98
|
+
|
|
99
|
+
const hits: { shipped: string; line: string }[] = [];
|
|
100
|
+
for (const dir of [doneDir(config), specDir(config)]) {
|
|
101
|
+
const abs = join(cwd, dir);
|
|
102
|
+
let names: string[];
|
|
103
|
+
try {
|
|
104
|
+
names = readdirSync(abs)
|
|
105
|
+
.filter((n) => n.endsWith(".md"))
|
|
106
|
+
.sort();
|
|
107
|
+
} catch {
|
|
108
|
+
continue; // dir missing — a repo without shipped plans yet
|
|
109
|
+
}
|
|
110
|
+
const label = dir.split("/").pop() ?? dir;
|
|
111
|
+
for (const n of names) {
|
|
112
|
+
let content: string;
|
|
113
|
+
try {
|
|
114
|
+
content = readFileSync(join(abs, n), "utf-8");
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!keys.some((k) => content.includes(k))) continue;
|
|
119
|
+
// The H1 usually repeats the filename ("PLAN-x.md — real title") and
|
|
120
|
+
// the filename is already the link text — keep only what it adds.
|
|
121
|
+
const title = (content.match(/^#\s+(.+)$/m)?.[1] ?? n)
|
|
122
|
+
.trim()
|
|
123
|
+
.replace(/^(?:PLAN|SPEC)-[\w.-]+\s+[—-]\s+/, "");
|
|
124
|
+
const shipped = content.match(/shipped\s+(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
125
|
+
hits.push({
|
|
126
|
+
shipped,
|
|
127
|
+
line: `- ✅ ตัดสินไปแล้ว: [${n}](../${label}/${n}) — ${title}${shipped ? ` (shipped ${shipped})` : ""} \`(fapony plan-seed)\``,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (hits.length === 0) return placeholder;
|
|
132
|
+
// Newest decision first — alphabetical order cuts by filename, which is the
|
|
133
|
+
// one thing that says nothing about whether the decision still binds.
|
|
134
|
+
// Undated (specs, unshipped) sort last rather than disappearing.
|
|
135
|
+
hits.sort((a, b) =>
|
|
136
|
+
a.shipped < b.shipped ? 1 : a.shipped > b.shipped ? -1 : 0,
|
|
137
|
+
);
|
|
138
|
+
const shown = hits.slice(0, MAX_PRIOR_ART).map((h) => h.line);
|
|
139
|
+
if (hits.length > MAX_PRIOR_ART) {
|
|
140
|
+
shown.push(`- … +${hits.length - MAX_PRIOR_ART} more ที่แตะ scope เดียวกัน`);
|
|
141
|
+
}
|
|
142
|
+
shown.push(placeholder);
|
|
143
|
+
return shown.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Context (fapony): mem decisions + model fit ---
|
|
147
|
+
|
|
148
|
+
function renderContextFapony(worktree: string): string {
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
const decisions = readRecentMemDecisions(worktree, 3).slice(0, 3);
|
|
151
|
+
lines.push(
|
|
152
|
+
decisions.length > 0
|
|
153
|
+
? `- Decisions on record (mem): ${decisions
|
|
154
|
+
.map(
|
|
155
|
+
(d) =>
|
|
156
|
+
`"${d.text.length > 140 ? `${d.text.slice(0, 139)}…` : d.text}"`,
|
|
157
|
+
)
|
|
158
|
+
.join(" · ")}`
|
|
159
|
+
: "- Decisions on record (mem): _(none — no mem log or empty)_",
|
|
160
|
+
);
|
|
161
|
+
const fits = computeModelFit(getStatsData().byRegime, worktree);
|
|
162
|
+
lines.push(
|
|
163
|
+
fits.length > 0
|
|
164
|
+
? `- Model fit (ledger, min N=5): ${fits
|
|
165
|
+
.map(
|
|
166
|
+
(f) =>
|
|
167
|
+
`${f.regime} → ${f.model} (N=${f.gates}, fail ${(f.failRate * 100).toFixed(0)}%)`,
|
|
168
|
+
)
|
|
169
|
+
.join(" · ")}`
|
|
170
|
+
: "- Model fit: _(not enough graded history yet)_",
|
|
171
|
+
);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- PLAN barrel ---
|
|
176
|
+
|
|
177
|
+
function planTemplate(
|
|
178
|
+
name: string,
|
|
179
|
+
priorArt: string,
|
|
180
|
+
contextFapony: string,
|
|
181
|
+
specLink: string | null,
|
|
182
|
+
): string {
|
|
183
|
+
return `---
|
|
184
|
+
kind: unit
|
|
185
|
+
status: active
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
# PLAN-${name} — (agent เติมชื่อเรื่อง)
|
|
189
|
+
|
|
190
|
+
> **Status:** 🚧 in-progress · **Created:** (agent เติมวันที่)
|
|
191
|
+
|
|
192
|
+
## TL;DR
|
|
193
|
+
- **What:** (agent เติม) · **Why:** (agent เติม) · **Done when:** (agent เติม)
|
|
194
|
+
- **Order:** (agent เติม)
|
|
195
|
+
- **Progress:**
|
|
196
|
+
- [ ] chunk 1 — (agent เติม)
|
|
197
|
+
|
|
198
|
+
## Context (fapony)
|
|
199
|
+
${contextFapony}
|
|
200
|
+
|
|
201
|
+
## 1. Goal (why)
|
|
202
|
+
_(agent เติม)_
|
|
203
|
+
|
|
204
|
+
## 2. Scope (do / don't do)
|
|
205
|
+
_(agent เติม)_
|
|
206
|
+
|
|
207
|
+
## 3. Done criteria (how we know it's finished)
|
|
208
|
+
_(agent เติม — ต้อง verify ได้)_
|
|
209
|
+
|
|
210
|
+
## 4. Constraints / Hard rules (must not violate)
|
|
211
|
+
_(agent เติม)_
|
|
212
|
+
|
|
213
|
+
## 5. Risks & Escape hatches (if it fails)
|
|
214
|
+
_(agent เติม)_
|
|
215
|
+
|
|
216
|
+
## 6. Steps (what in which order)
|
|
217
|
+
1. _(agent เติม — แต่ละขั้น verify ได้)_
|
|
218
|
+
|
|
219
|
+
## 7. Examples
|
|
220
|
+
${
|
|
221
|
+
specLink
|
|
222
|
+
? `→ ${specLink} (signature อยู่ spec ไม่ใช่ plan)`
|
|
223
|
+
: "_(agent เติม — หรือเพิ่ม SPEC ด้วย plan-seed --spec)_"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
## 8. References
|
|
227
|
+
${priorArt}
|
|
228
|
+
|
|
229
|
+
## Context (agent)
|
|
230
|
+
_(slot ว่าง — agent dump graph/code-summary ของตัวเอง)_
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- SPEC chunked (--spec) ---
|
|
235
|
+
// One chunk per module (top-level dir, recursed), verbatim declaration
|
|
236
|
+
// signatures per file inside — the exact thing plan §7 must not hold.
|
|
237
|
+
|
|
238
|
+
interface Chunk {
|
|
239
|
+
slug: string;
|
|
240
|
+
title: string;
|
|
241
|
+
body: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// review-seed's cap shape: keep the head, always say how much was cut — a
|
|
245
|
+
// silent cut is indistinguishable from "that was everything".
|
|
246
|
+
function capLines(lines: string[], cap: number, what: string): string[] {
|
|
247
|
+
if (lines.length <= cap) return lines;
|
|
248
|
+
const rest = lines.length - (cap - 1);
|
|
249
|
+
const kept = lines.slice(0, cap - 1);
|
|
250
|
+
kept.push(`… +${rest} more ${what}`);
|
|
251
|
+
return kept;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function capChunk(c: Chunk): Chunk {
|
|
255
|
+
return {
|
|
256
|
+
...c,
|
|
257
|
+
body: capLines(
|
|
258
|
+
c.body.split("\n"),
|
|
259
|
+
MAX_CHUNK_LINES,
|
|
260
|
+
"signatures (narrow with --scope <path>)",
|
|
261
|
+
).join("\n"),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function fileLines(absFile: string): string[] {
|
|
266
|
+
let source: string;
|
|
267
|
+
try {
|
|
268
|
+
source = readFileSync(absFile, "utf-8");
|
|
269
|
+
} catch {
|
|
270
|
+
return ["_(unreadable)_"];
|
|
271
|
+
}
|
|
272
|
+
const scan = extractExports(source);
|
|
273
|
+
if (scan.error) return [`⚠ ${scan.error} — symbols not extractable`];
|
|
274
|
+
if (scan.symbols.length === 0) return ["_(no exports)_"];
|
|
275
|
+
let srcLines: string[] = [];
|
|
276
|
+
try {
|
|
277
|
+
srcLines = source.split("\n");
|
|
278
|
+
} catch {
|
|
279
|
+
// fall through to name-only below
|
|
280
|
+
}
|
|
281
|
+
return scan.symbols.map((s) => {
|
|
282
|
+
const raw = srcLines[s.line - 1]?.trim() ?? "";
|
|
283
|
+
const sig = raw.length > SIG_MAX ? `${raw.slice(0, SIG_MAX - 1)}…` : raw;
|
|
284
|
+
return ` - \`${s.line}\` ${s.kind} ${sig || s.name}`;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isSourceFile(st: { isDirectory(): boolean }, name: string): boolean {
|
|
289
|
+
return !st.isDirectory() && SCAN_EXTS.has(name.slice(name.lastIndexOf(".")));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function moduleChunkFiles(absDir: string, entries: string[]): string[] {
|
|
293
|
+
const lines: string[] = [];
|
|
294
|
+
for (const name of entries) {
|
|
295
|
+
const child = join(absDir, name);
|
|
296
|
+
let st: import("node:fs").Stats;
|
|
297
|
+
try {
|
|
298
|
+
st = statSync(child);
|
|
299
|
+
} catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (isSourceFile(st, name)) {
|
|
303
|
+
lines.push(`### ${name}`, "", ...fileLines(child), "");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function moduleChunk(absDir: string, rel: string): Chunk | null {
|
|
310
|
+
let entries: string[];
|
|
311
|
+
try {
|
|
312
|
+
entries = readdirSync(absDir, { withFileTypes: true })
|
|
313
|
+
.filter(
|
|
314
|
+
(e) =>
|
|
315
|
+
!e.isSymbolicLink() &&
|
|
316
|
+
!e.name.startsWith(".") &&
|
|
317
|
+
!isSkippedDir(e.name, absDir),
|
|
318
|
+
)
|
|
319
|
+
.map((e) => e.name)
|
|
320
|
+
.sort();
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const lines: string[] = moduleChunkFiles(absDir, entries);
|
|
325
|
+
for (const name of entries) {
|
|
326
|
+
const child = join(absDir, name);
|
|
327
|
+
let st: import("node:fs").Stats;
|
|
328
|
+
try {
|
|
329
|
+
st = statSync(child);
|
|
330
|
+
} catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!st.isDirectory()) continue;
|
|
334
|
+
const nested = moduleChunk(child, `${rel}/${name}`);
|
|
335
|
+
if (nested) lines.push(`**${name}/**`, "", nested.body, "");
|
|
336
|
+
}
|
|
337
|
+
if (lines.length === 0) return null;
|
|
338
|
+
return { slug: slug(rel), title: rel, body: lines.join("\n").trimEnd() };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// One scope root = root files first, then one chunk per top-level dir. When the
|
|
342
|
+
// root is a single file, produce one chunk for it (readdirSync on a file throws,
|
|
343
|
+
// so handle it before the dir walk).
|
|
344
|
+
function rootChunks(absDir: string): Chunk[] {
|
|
345
|
+
const chunks: Chunk[] = [];
|
|
346
|
+
let st: import("node:fs").Stats;
|
|
347
|
+
try {
|
|
348
|
+
st = statSync(absDir);
|
|
349
|
+
} catch {
|
|
350
|
+
return chunks;
|
|
351
|
+
}
|
|
352
|
+
if (!st.isDirectory()) {
|
|
353
|
+
// Single-file scope: one chunk, the file's own signatures.
|
|
354
|
+
const name = basename(absDir);
|
|
355
|
+
if (!isSourceFile(st, name)) return chunks;
|
|
356
|
+
const lines = fileLines(absDir);
|
|
357
|
+
if (lines.length === 0) return chunks;
|
|
358
|
+
return [
|
|
359
|
+
capChunk({
|
|
360
|
+
slug: slug(name),
|
|
361
|
+
title: name,
|
|
362
|
+
body: lines.join("\n"),
|
|
363
|
+
}),
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
let entries: string[] = [];
|
|
367
|
+
try {
|
|
368
|
+
entries = readdirSync(absDir, { withFileTypes: true })
|
|
369
|
+
.filter(
|
|
370
|
+
(e) =>
|
|
371
|
+
!e.isSymbolicLink() &&
|
|
372
|
+
!e.name.startsWith(".") &&
|
|
373
|
+
!isSkippedDir(e.name, absDir),
|
|
374
|
+
)
|
|
375
|
+
.map((e) => e.name)
|
|
376
|
+
.sort();
|
|
377
|
+
} catch {
|
|
378
|
+
return chunks;
|
|
379
|
+
}
|
|
380
|
+
const rootLines = moduleChunkFiles(absDir, entries);
|
|
381
|
+
if (rootLines.length > 0) {
|
|
382
|
+
chunks.push(
|
|
383
|
+
capChunk({
|
|
384
|
+
slug: "root",
|
|
385
|
+
title: "(root files)",
|
|
386
|
+
body: rootLines.join("\n").trimEnd(),
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
for (const name of entries) {
|
|
391
|
+
const child = join(absDir, name);
|
|
392
|
+
let st: import("node:fs").Stats;
|
|
393
|
+
try {
|
|
394
|
+
st = statSync(child);
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!st.isDirectory()) continue;
|
|
399
|
+
const c = moduleChunk(child, name);
|
|
400
|
+
if (c) chunks.push(capChunk(c));
|
|
401
|
+
}
|
|
402
|
+
return chunks;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Chunk titles/slugs are unique per scope root — with more than one root,
|
|
406
|
+
// prefix them so "src" from two different scopes doesn't collide.
|
|
407
|
+
function buildChunks(roots: string[], cwd: string): Chunk[] {
|
|
408
|
+
const chunks: Chunk[] = [];
|
|
409
|
+
const multi = roots.length > 1;
|
|
410
|
+
for (const root of roots) {
|
|
411
|
+
const rel = relative(cwd, root) || ".";
|
|
412
|
+
for (const c of rootChunks(root)) {
|
|
413
|
+
chunks.push(
|
|
414
|
+
multi
|
|
415
|
+
? {
|
|
416
|
+
slug: slug(`${rel}-${c.slug}`),
|
|
417
|
+
title:
|
|
418
|
+
c.title === "(root files)"
|
|
419
|
+
? `${rel}/ (root files)`
|
|
420
|
+
: `${rel}/${c.title}`,
|
|
421
|
+
body: c.body,
|
|
422
|
+
}
|
|
423
|
+
: c,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return chunks;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function specTemplate(
|
|
431
|
+
name: string,
|
|
432
|
+
chunks: Chunk[],
|
|
433
|
+
scopeEcho: string | null,
|
|
434
|
+
): string {
|
|
435
|
+
const index = chunks.map((c) => `- [${c.title}](#${c.slug})`).join("\n");
|
|
436
|
+
// The index is one string with a newline per chunk; budgeting it as one line
|
|
437
|
+
// undershot the cap by that many lines (the 18k-SPEC failure mode). Cap the
|
|
438
|
+
// index separately so the body's capLines has a bounded head to work with.
|
|
439
|
+
const fixedHead = [
|
|
440
|
+
`# SPEC-${name} — (agent เติมชื่อเรื่อง)`,
|
|
441
|
+
"",
|
|
442
|
+
`> **Used by:** PLAN-${name} — signatures below come from a live source scan — re-seed after structural changes.`,
|
|
443
|
+
...(scopeEcho ? [`> **Scope:** ${scopeEcho}`] : []),
|
|
444
|
+
"",
|
|
445
|
+
"## Chunk index",
|
|
446
|
+
"",
|
|
447
|
+
];
|
|
448
|
+
const indexLines = index
|
|
449
|
+
? index.split("\n")
|
|
450
|
+
: ["_(no chunks — no source files found)_"];
|
|
451
|
+
const budgetIndex = Math.max(MAX_SPEC_LINES - fixedHead.length - 1, 0);
|
|
452
|
+
const cappedIndex = capLines(
|
|
453
|
+
indexLines,
|
|
454
|
+
budgetIndex,
|
|
455
|
+
"chunks (narrow with --scope <path>)",
|
|
456
|
+
);
|
|
457
|
+
const head = [...fixedHead, ...cappedIndex, ""];
|
|
458
|
+
const tail = [
|
|
459
|
+
"## (agent เติม — wireframes / edge cases / API shapes ที่ plan อ้างถึง)",
|
|
460
|
+
];
|
|
461
|
+
const bodyLines = chunks.flatMap((c) => [
|
|
462
|
+
`## <a id="${c.slug}"></a>${c.title}`,
|
|
463
|
+
"",
|
|
464
|
+
...c.body.split("\n"),
|
|
465
|
+
"",
|
|
466
|
+
]);
|
|
467
|
+
// Whole-file cap runs last and the agent section survives it, same way
|
|
468
|
+
// review-seed reserves its disclaimer: reserve the tail, cut the middle,
|
|
469
|
+
// say how much was dropped.
|
|
470
|
+
const budget = Math.max(MAX_SPEC_LINES - head.length - tail.length, 0);
|
|
471
|
+
const cappedBody = capLines(
|
|
472
|
+
bodyLines,
|
|
473
|
+
budget,
|
|
474
|
+
"lines (narrow with --scope <path>)",
|
|
475
|
+
);
|
|
476
|
+
return `${[...head, ...cappedBody, ...tail].join("\n")}\n`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// --- CLI entry ---
|
|
480
|
+
|
|
481
|
+
export function cmdPlanSeed(args: string[]): void {
|
|
482
|
+
const usage = "usage: fapony plan-seed <name> [--spec] [--scope <path>]...";
|
|
483
|
+
// Positional parse, not args.find(!startsWith("--")) — a --scope VALUE is
|
|
484
|
+
// a non-flag argument and must never be mistaken for the plan name.
|
|
485
|
+
let name: string | undefined;
|
|
486
|
+
let withSpec = false;
|
|
487
|
+
const scopeArgs: string[] = [];
|
|
488
|
+
for (let i = 0; i < args.length; i++) {
|
|
489
|
+
const a = args[i];
|
|
490
|
+
if (a === "--spec") {
|
|
491
|
+
withSpec = true;
|
|
492
|
+
} else if (a === "--scope") {
|
|
493
|
+
const v = args[i + 1];
|
|
494
|
+
if (v === undefined || v.startsWith("--")) {
|
|
495
|
+
console.error(`plan-seed: --scope needs a path\n${usage}`);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
scopeArgs.push(v);
|
|
499
|
+
i++;
|
|
500
|
+
} else if (a.startsWith("--")) {
|
|
501
|
+
console.error(`plan-seed: unknown flag "${a}"\n${usage}`);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
} else if (name === undefined) {
|
|
504
|
+
name = a;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!name) {
|
|
508
|
+
console.error(usage);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
const cwd = process.cwd();
|
|
512
|
+
const config = loadConfig(join(cwd, "fapony.config.json"));
|
|
513
|
+
// Resolve the git worktree root so mem/ledger queries hit the same key
|
|
514
|
+
// state.db uses (git rev-parse --show-toplevel). Running from a subdir
|
|
515
|
+
// would otherwise mismatch: stats return empty, Context (fapony) always
|
|
516
|
+
// prints "(not enough graded history yet)".
|
|
517
|
+
let worktree: string;
|
|
518
|
+
try {
|
|
519
|
+
worktree = execSync("git rev-parse --show-toplevel", {
|
|
520
|
+
encoding: "utf-8",
|
|
521
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
522
|
+
}).trim();
|
|
523
|
+
} catch {
|
|
524
|
+
worktree = cwd;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Scope: explicit paths win; default is the cwd. Resolved absolutes,
|
|
528
|
+
// deduped — the same path twice is one scope. Nested roots are pruned:
|
|
529
|
+
// --scope src --scope src/utils would double-count files in src/utils.
|
|
530
|
+
const requested = [...new Set(scopeArgs.map((s) => resolve(cwd, s)))];
|
|
531
|
+
// Sort by path length (shortest first) so a parent always comes before its
|
|
532
|
+
// children; then drop any root whose ancestor is already in the list.
|
|
533
|
+
requested.sort((a, b) => a.length - b.length);
|
|
534
|
+
const roots: string[] = [];
|
|
535
|
+
for (const r of requested) {
|
|
536
|
+
if (roots.some((accepted) => r.startsWith(`${accepted}${sep}`))) continue;
|
|
537
|
+
roots.push(r);
|
|
538
|
+
}
|
|
539
|
+
for (const r of roots) {
|
|
540
|
+
if (!existsSync(r)) {
|
|
541
|
+
console.error(`plan-seed: scope not found: ${r}`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (roots.length === 0) roots.push(resolve(cwd, "."));
|
|
546
|
+
// Past a few hundred files the SPEC caps start eating output — say why it
|
|
547
|
+
// looks short instead of letting the seed silently truncate. 300 is a guess.
|
|
548
|
+
let totalFiles = 0;
|
|
549
|
+
for (const r of roots) totalFiles += scopeSourceFiles(r).length;
|
|
550
|
+
if (totalFiles > SCOPE_WARN_FILES) {
|
|
551
|
+
console.error(
|
|
552
|
+
`plan-seed: ${totalFiles} source files in scope (> ${SCOPE_WARN_FILES}) — output is capped; narrow with --scope <path>`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const planDirAbs = join(cwd, planDir(config));
|
|
557
|
+
const planPath = join(planDirAbs, `PLAN-${name}.md`);
|
|
558
|
+
if (existsSync(planPath)) {
|
|
559
|
+
console.error(
|
|
560
|
+
`${planPath} already exists — not overwriting. ใช้ชื่อใหม่ เช่น PLAN-${name}-v2`,
|
|
561
|
+
);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const priorArt = renderPriorArt(cwd, config, roots);
|
|
566
|
+
const contextFapony = renderContextFapony(worktree);
|
|
567
|
+
|
|
568
|
+
let specLink: string | null = null;
|
|
569
|
+
if (withSpec) {
|
|
570
|
+
const specDirAbs = join(cwd, specDir(config));
|
|
571
|
+
const specPath = join(specDirAbs, `SPEC-${name}.md`);
|
|
572
|
+
if (existsSync(specPath)) {
|
|
573
|
+
console.error(
|
|
574
|
+
`${specPath} already exists — not overwriting. ใช้ชื่อใหม่ เช่น SPEC-${name}-v2`,
|
|
575
|
+
);
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
const chunks = buildChunks(roots, cwd);
|
|
579
|
+
mkdirSync(specDirAbs, { recursive: true });
|
|
580
|
+
writeFileSync(
|
|
581
|
+
specPath,
|
|
582
|
+
specTemplate(
|
|
583
|
+
name,
|
|
584
|
+
chunks,
|
|
585
|
+
requested.length > 0
|
|
586
|
+
? roots.map((r) => relative(cwd, r) || ".").join(", ")
|
|
587
|
+
: null,
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
specLink = `../${specDir(config).split("/").pop()}/SPEC-${name}.md`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
mkdirSync(planDirAbs, { recursive: true });
|
|
594
|
+
writeFileSync(
|
|
595
|
+
planPath,
|
|
596
|
+
planTemplate(name, priorArt, contextFapony, specLink),
|
|
597
|
+
);
|
|
598
|
+
console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
|
|
599
|
+
}
|