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/map.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// src/map.ts — extractExports(): an on-demand source index for TS/JS projects.
|
|
2
|
+
//
|
|
3
|
+
// Library only. The `fapony map` command this grew out of was deleted once
|
|
4
|
+
// plan-seed and review-seed were its only callers — see PLAN-code-map.
|
|
5
|
+
//
|
|
6
|
+
// Zero persistence: every export list is read from the filesystem and thrown
|
|
7
|
+
// away. No index file, no table, no MCP tool, no cache — standing cost to every
|
|
8
|
+
// other session is exactly zero (the opposite of a cached graph). Read-only.
|
|
9
|
+
//
|
|
10
|
+
// Export names come from a line-based scan; Bun.Transpiler.scan() is the parse
|
|
11
|
+
// gate (throws => "parse error", never guessed away). scan() itself is not the
|
|
12
|
+
// export source: it returns names without lines and drops type-only exports
|
|
13
|
+
// (`export type`, `export interface`, `type X` inside braces) that a code map
|
|
14
|
+
// needs. See SPEC-code-map.md.
|
|
15
|
+
//
|
|
16
|
+
// A content-hash cache was prototyped and cut: hashing content requires reading
|
|
17
|
+
// every file, and reading already costs more than the parse the cache would
|
|
18
|
+
// save, so it bought no wall-clock win at L1 (measured 2026-09-15). It is
|
|
19
|
+
// deferred to L2 (crux excerpt), where per-file work is heavy enough to pay.
|
|
20
|
+
|
|
21
|
+
export type ExportKind =
|
|
22
|
+
| "fn"
|
|
23
|
+
| "class"
|
|
24
|
+
| "const"
|
|
25
|
+
| "type"
|
|
26
|
+
| "interface"
|
|
27
|
+
| "enum"
|
|
28
|
+
| "namespace"
|
|
29
|
+
| "default"
|
|
30
|
+
| "re-export";
|
|
31
|
+
|
|
32
|
+
export interface ExportSymbol {
|
|
33
|
+
name: string;
|
|
34
|
+
/** 1-based line where the symbol is declared. */
|
|
35
|
+
line: number;
|
|
36
|
+
kind: ExportKind;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExportScan {
|
|
40
|
+
symbols: ExportSymbol[];
|
|
41
|
+
error: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Parse gate ---
|
|
45
|
+
|
|
46
|
+
let transpiler: Bun.Transpiler | null = null;
|
|
47
|
+
|
|
48
|
+
interface ScanResult {
|
|
49
|
+
error: string | null;
|
|
50
|
+
exports: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scanSource(source: string): ScanResult {
|
|
54
|
+
try {
|
|
55
|
+
if (!transpiler) transpiler = new Bun.Transpiler({ loader: "tsx" });
|
|
56
|
+
const scanned = transpiler.scan(source) as { exports: string[] };
|
|
57
|
+
return { error: null, exports: scanned.exports };
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return {
|
|
60
|
+
error: e instanceof Error ? e.message.split("\n")[0] : "Parse error",
|
|
61
|
+
exports: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Identifiers in one line of code, skipping strings, template spans, and
|
|
67
|
+
// comments. Same-line only — enough to find extra bindings on a declaration
|
|
68
|
+
// line without treating sample text as code.
|
|
69
|
+
function codeIdentifiers(line: string): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
let cur = "";
|
|
72
|
+
const flush = () => {
|
|
73
|
+
if (/^[A-Za-z_$][\w$]*$/.test(cur)) out.push(cur);
|
|
74
|
+
cur = "";
|
|
75
|
+
};
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < line.length) {
|
|
78
|
+
const c = line[i];
|
|
79
|
+
const next = line[i + 1] ?? "";
|
|
80
|
+
if (c === "/" && next === "/") break;
|
|
81
|
+
if (c === "/" && next === "*") {
|
|
82
|
+
const end = line.indexOf("*/", i + 2);
|
|
83
|
+
i = end < 0 ? line.length : end + 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
87
|
+
const quote = c;
|
|
88
|
+
i++;
|
|
89
|
+
while (i < line.length && line[i] !== quote) {
|
|
90
|
+
i += line[i] === "\\" ? 2 : 1;
|
|
91
|
+
}
|
|
92
|
+
i++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (/[A-Za-z_$0-9]/.test(c)) {
|
|
96
|
+
cur += c;
|
|
97
|
+
} else {
|
|
98
|
+
flush();
|
|
99
|
+
}
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
flush();
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Export extraction (name + line + kind) ---
|
|
107
|
+
|
|
108
|
+
const RE = {
|
|
109
|
+
fn: /^export\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
|
|
110
|
+
cls: /^export\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
|
|
111
|
+
iface: /^export\s+interface\s+([A-Za-z_$][\w$]*)/,
|
|
112
|
+
typ: /^export\s+type\s+([A-Za-z_$][\w$]*)/,
|
|
113
|
+
en: /^export\s+(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)/,
|
|
114
|
+
ns: /^export\s+namespace\s+([A-Za-z_$][\w$]*)/,
|
|
115
|
+
starAs: /^export\s*\*\s*as\s+([A-Za-z_$][\w$]*)/,
|
|
116
|
+
star: /^export\s*\*\s+from\b/,
|
|
117
|
+
brace: /^export\s+(?:type\s+)?\{/,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Names in one `export { ... }` fragment. Handles `a as b` (keep b),
|
|
121
|
+
// per-name `type X`, and a whole block that started as `export type { ... }`
|
|
122
|
+
// (blockType is set by the caller from the opening line and carried across
|
|
123
|
+
// fragments, so every name in a multi-line type block stays `type`).
|
|
124
|
+
function braceNames(
|
|
125
|
+
frag: string,
|
|
126
|
+
blockType = false,
|
|
127
|
+
): { name: string; kind: ExportKind }[] {
|
|
128
|
+
let text = frag.replace(/[{};]/g, " ");
|
|
129
|
+
text = text.replace(/\bfrom\b[\s\S]*$/, " ");
|
|
130
|
+
text = text.replace(/^export\s+/, "");
|
|
131
|
+
const blockIsType = blockType || /^type\b/.test(text.trim());
|
|
132
|
+
text = text.replace(/^type\s+/, "");
|
|
133
|
+
const out: { name: string; kind: ExportKind }[] = [];
|
|
134
|
+
for (let part of text.split(",")) {
|
|
135
|
+
part = part.trim();
|
|
136
|
+
if (!part) continue;
|
|
137
|
+
let kind: ExportKind = blockIsType ? "type" : "re-export";
|
|
138
|
+
if (/^type\s+/.test(part)) {
|
|
139
|
+
part = part.replace(/^type\s+/, "");
|
|
140
|
+
kind = "type";
|
|
141
|
+
}
|
|
142
|
+
const as = part.split(/\s+as\s+/);
|
|
143
|
+
const name = (as.length > 1 ? as[as.length - 1] : as[0]).trim();
|
|
144
|
+
if (name === "export") continue;
|
|
145
|
+
if (name !== "default" && !/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
|
146
|
+
out.push({ name, kind });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const VAR_DECL_RE = /^export\s+(?:const|let|var)\b/;
|
|
152
|
+
|
|
153
|
+
export function extractExports(source: string): ExportScan {
|
|
154
|
+
const scanned = scanSource(source);
|
|
155
|
+
if (scanned.error) return { symbols: [], error: scanned.error };
|
|
156
|
+
|
|
157
|
+
// Ambient declarations (`export declare ...`) are invisible to scan(), so
|
|
158
|
+
// validate value names against the union of the real source and a
|
|
159
|
+
// declare-stripped variant scanned on the same lines.
|
|
160
|
+
const stripped = source.replace(
|
|
161
|
+
/^([ \t]*)export\s+declare\s+/gm,
|
|
162
|
+
"$1export ",
|
|
163
|
+
);
|
|
164
|
+
const strippedScan = scanSource(stripped);
|
|
165
|
+
const valid = new Set([
|
|
166
|
+
...scanned.exports,
|
|
167
|
+
...(strippedScan.error ? [] : strippedScan.exports),
|
|
168
|
+
]);
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
// scan() is blind to namespaces (and to ambient `declare`), so those kinds
|
|
171
|
+
// stay regex-authoritative; everything else must appear in the parsed export
|
|
172
|
+
// set, which is what filters sample text out of comments and strings.
|
|
173
|
+
const SCAN_BLIND: ExportKind[] = ["type", "interface", "namespace"];
|
|
174
|
+
const keep = (name: string, kind: ExportKind, declared: boolean): boolean => {
|
|
175
|
+
if (name === "*") return true;
|
|
176
|
+
if (SCAN_BLIND.includes(kind)) return true;
|
|
177
|
+
if (seen.has(name)) return false;
|
|
178
|
+
if (!declared && !valid.has(name)) return false;
|
|
179
|
+
seen.add(name);
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
182
|
+
const push = (
|
|
183
|
+
out: ExportSymbol[],
|
|
184
|
+
name: string,
|
|
185
|
+
line: number,
|
|
186
|
+
kind: ExportKind,
|
|
187
|
+
declared: boolean,
|
|
188
|
+
): void => {
|
|
189
|
+
if (keep(name, kind, declared)) out.push({ name, line, kind });
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const lines = source.split("\n");
|
|
193
|
+
const out: ExportSymbol[] = [];
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
let t = lines[i].trim();
|
|
197
|
+
if (!t.startsWith("export")) continue;
|
|
198
|
+
const declared = /^export\s+declare\s+/.test(t);
|
|
199
|
+
t = t.replace(/^export\s+declare\s+/, "export ");
|
|
200
|
+
const line = i + 1;
|
|
201
|
+
let m: RegExpMatchArray | null;
|
|
202
|
+
|
|
203
|
+
if (RE.brace.test(t)) {
|
|
204
|
+
const blockType = /^export\s+type\b/.test(t);
|
|
205
|
+
const frags = [{ text: t, line }];
|
|
206
|
+
let joined = t;
|
|
207
|
+
let j = i;
|
|
208
|
+
while (!joined.includes("}") && j + 1 < lines.length) {
|
|
209
|
+
j++;
|
|
210
|
+
frags.push({ text: lines[j].trim(), line: j + 1 });
|
|
211
|
+
joined += ` ${lines[j].trim()}`;
|
|
212
|
+
}
|
|
213
|
+
for (const f of frags) {
|
|
214
|
+
for (const s of braceNames(f.text, blockType)) {
|
|
215
|
+
push(out, s.name, f.line, s.kind, declared);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
i = j;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (/^export\s+default\b/.test(t)) {
|
|
222
|
+
push(out, "default", line, "default", declared);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if ((m = t.match(RE.fn))) {
|
|
226
|
+
push(out, m[1], line, "fn", declared);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if ((m = t.match(RE.cls))) {
|
|
230
|
+
push(out, m[1], line, "class", declared);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if ((m = t.match(RE.iface))) {
|
|
234
|
+
push(out, m[1], line, "interface", declared);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if ((m = t.match(RE.en))) {
|
|
238
|
+
push(out, m[1], line, "enum", declared);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if ((m = t.match(RE.ns))) {
|
|
242
|
+
push(out, m[1], line, "namespace", declared);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if ((m = t.match(RE.starAs))) {
|
|
246
|
+
push(out, m[1], line, "namespace", declared);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (RE.star.test(t)) {
|
|
250
|
+
push(out, "*", line, "re-export", declared);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if ((m = t.match(RE.typ))) {
|
|
254
|
+
push(out, m[1], line, "type", declared);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const varMatch = t.match(VAR_DECL_RE);
|
|
258
|
+
if (varMatch) {
|
|
259
|
+
// A `const`/`let`/`var` line can bind several names
|
|
260
|
+
// (`a = 1, b = 2`, `{ a, b } = …`, `[x] = …`). The scan set says which
|
|
261
|
+
// identifiers on this line are real exports; initializers and sample
|
|
262
|
+
// text never are.
|
|
263
|
+
for (const name of codeIdentifiers(t.slice(varMatch[0].length))) {
|
|
264
|
+
if (valid.has(name)) push(out, name, line, "const", declared);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { symbols: out, error: null };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// First comment content in the first ~12 lines + the line it sits on. Used only
|
|
272
|
+
// to guess a leaf dir's objective — a module header, not a doc parser.
|
|
273
|
+
|
|
274
|
+
// Objective guess for a dir with no subdirs: the module header of the most
|
|
275
|
+
// descriptive file. Prefers `// path — desc` (the repo's convention), then a
|
|
276
|
+
// first-line comment, then any comment; ties break to the lexical-first file.
|
|
277
|
+
// Capped: a guess reads at most MAX_OBJECTIVE_FILES files, never a whole tree.
|
|
278
|
+
|
|
279
|
+
// --- Declaration slice (indent-out, no parser) ---
|
|
280
|
+
|
|
281
|
+
// Slice from the declaration line until the first line at the declaration's
|
|
282
|
+
// own indent level that is non-blank (indent-out). Raw indentation as the
|
|
283
|
+
// close signal means no brace counting and no parse — a one-liner returns
|
|
284
|
+
// itself, nested blocks and object literals never return to the base indent
|
|
285
|
+
// until the declaration is over. The closing line is the exception indent-out
|
|
286
|
+
// cannot see: `}` sits AT the declaration indent, so the walk stops one line
|
|
287
|
+
// short of it. Take that line when it is nothing but closers — which is why
|
|
288
|
+
// the slice a caller pastes into an edit is syntactically whole.
|
|
289
|
+
// Callers: review-seed --body, conventions seeder (wrapper detection).
|
|
290
|
+
const MAX_BODY_LINES = 80;
|
|
291
|
+
|
|
292
|
+
export function extractBody(source: string, line: number): string[] {
|
|
293
|
+
const lines = source.split("\n");
|
|
294
|
+
const start = line - 1;
|
|
295
|
+
if (start < 0 || start >= lines.length) return [];
|
|
296
|
+
const decl = lines[start];
|
|
297
|
+
if (decl.trim() === "") return [];
|
|
298
|
+
const base = decl.match(/^\s*/)?.[0].length ?? 0;
|
|
299
|
+
const out: string[] = [decl];
|
|
300
|
+
for (
|
|
301
|
+
let i = start + 1;
|
|
302
|
+
i < lines.length && out.length < MAX_BODY_LINES;
|
|
303
|
+
i++
|
|
304
|
+
) {
|
|
305
|
+
const l = lines[i];
|
|
306
|
+
if (l.trim() === "") {
|
|
307
|
+
out.push(l);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if ((l.match(/^\s*/)?.[0].length ?? 0) <= base) {
|
|
311
|
+
// Closers only (`}`, `};`, `});`) — never the next declaration.
|
|
312
|
+
if (/^[)\]}]+[;,]?$/.test(l.trim())) out.push(l);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
out.push(l);
|
|
316
|
+
}
|
|
317
|
+
// Trailing blank lines inside the slice are padding, not body.
|
|
318
|
+
while (out.length > 1 && out[out.length - 1].trim() === "") out.pop();
|
|
319
|
+
return out;
|
|
320
|
+
}
|
package/src/math.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/math.ts — pure numeric helpers shared across modules
|
|
2
|
+
|
|
3
|
+
/** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
|
|
4
|
+
export function minutesBetween(a: string, b: string): number {
|
|
5
|
+
const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
|
|
6
|
+
const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
|
|
7
|
+
return (t1 - t0) / 60000;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Arithmetic mean of a numeric array. 0 for empty arrays. */
|
|
11
|
+
export function avg(xs: number[]): number {
|
|
12
|
+
return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
|
|
13
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// src/mcp/evidence.ts — allowlist-based evidence collector
|
|
2
|
+
//
|
|
3
|
+
// Runs only commands listed in the evidence allowlist (default
|
|
4
|
+
// `.fapony/evidence.json`, movable via paths.evidenceFile) inside the worktree,
|
|
5
|
+
// sequentially, each under assertSafe() + per-command timeout. Returns
|
|
6
|
+
// structured EvidenceItem[] without storing secret output.
|
|
7
|
+
//
|
|
8
|
+
// Agent-proposed commands are NEVER executed here — they are recorded as
|
|
9
|
+
// `unverified` claims (provenance: agent_report), matching the contract that
|
|
10
|
+
// `verified:false` means "fapony did not run this".
|
|
11
|
+
//
|
|
12
|
+
// Rule 5b: this module writes nothing — it only reads config and runs
|
|
13
|
+
// allowlisted commands (cwd = worktree). Evidence has to stay the owner's own
|
|
14
|
+
// record; a claim fapony fabricated would be worth nothing. (Previously cited
|
|
15
|
+
// the blanket "fapony never writes to the worktree" rule, dropped 2026-09-17.)
|
|
16
|
+
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join, relative } from "node:path";
|
|
20
|
+
import { type Config, evidenceFile, safetyDeny } from "../db/index.js";
|
|
21
|
+
import { assertSafe } from "../safety.js";
|
|
22
|
+
import type { EvidenceItem, EvidenceStatus } from "./primitives.js";
|
|
23
|
+
|
|
24
|
+
// --- Config shape (.fapony/evidence.json by default, paths.evidenceFile to move it) ---
|
|
25
|
+
|
|
26
|
+
interface EvidenceCommand {
|
|
27
|
+
name: string;
|
|
28
|
+
cmd: string;
|
|
29
|
+
timeout_ms?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface EvidenceConfig {
|
|
33
|
+
commands: EvidenceCommand[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
37
|
+
const TOTAL_TIMEOUT_MS = 180_000;
|
|
38
|
+
|
|
39
|
+
const VERIFIED_PROVENANCE = { verified: true, source: "fapony_cli" } as const;
|
|
40
|
+
const AGENT_PROVENANCE = { verified: false, source: "agent_report" } as const;
|
|
41
|
+
|
|
42
|
+
// --- App-scoped resolution ---
|
|
43
|
+
|
|
44
|
+
// Monorepo group dirs — same order as templates/mem/store.ts (first hit wins there;
|
|
45
|
+
// here every file must already agree on one group+app, so order never matters).
|
|
46
|
+
const APP_GROUPS = ["apps", "packages", "services"];
|
|
47
|
+
|
|
48
|
+
function relOf(worktree: string, f: string): string {
|
|
49
|
+
const r = f.startsWith(`${worktree}/`) ? relative(worktree, f) : f;
|
|
50
|
+
return r.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Which evidence.json applies to this report — pure over (worktree, files).
|
|
55
|
+
*
|
|
56
|
+
* No new fields: the app is inferred from changed files already present in
|
|
57
|
+
* the report facts (precedent: activeSession.ts infers model from spans
|
|
58
|
+
* instead of asking the caller). Every file must sit under the same
|
|
59
|
+
* <group>/<app>/ and that app must have its own .fapony/evidence.json —
|
|
60
|
+
* otherwise (mixed apps, no files[], missing file) the root allowlist
|
|
61
|
+
* applies, exactly the old behavior. A declared paths.evidenceFile always
|
|
62
|
+
* wins: config beats guessing.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveEvidencePath(
|
|
65
|
+
worktree: string,
|
|
66
|
+
files: string[] = [],
|
|
67
|
+
config?: Config,
|
|
68
|
+
): string {
|
|
69
|
+
if (config?.paths?.evidenceFile)
|
|
70
|
+
return join(worktree, config.paths.evidenceFile);
|
|
71
|
+
const rels = files
|
|
72
|
+
.filter((f) => typeof f === "string" && f.trim())
|
|
73
|
+
.map((f) => relOf(worktree, f));
|
|
74
|
+
if (rels.length > 0) {
|
|
75
|
+
const heads = rels.map((r) => r.split("/"));
|
|
76
|
+
const [group, app] = heads[0];
|
|
77
|
+
if (
|
|
78
|
+
group &&
|
|
79
|
+
app &&
|
|
80
|
+
APP_GROUPS.includes(group) &&
|
|
81
|
+
heads.every(([g, a]) => g === group && a === app)
|
|
82
|
+
) {
|
|
83
|
+
const candidate = join(worktree, group, app, ".fapony", "evidence.json");
|
|
84
|
+
if (existsSync(candidate)) return candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return join(worktree, evidenceFile(config));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Allowlist reader ---
|
|
91
|
+
|
|
92
|
+
export function readEvidenceConfig(
|
|
93
|
+
worktree: string,
|
|
94
|
+
config?: Config,
|
|
95
|
+
files: string[] = [],
|
|
96
|
+
): EvidenceConfig | null {
|
|
97
|
+
const configPath = resolveEvidencePath(worktree, files, config);
|
|
98
|
+
if (!existsSync(configPath)) return null;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
102
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
103
|
+
if (
|
|
104
|
+
parsed &&
|
|
105
|
+
typeof parsed === "object" &&
|
|
106
|
+
Array.isArray((parsed as EvidenceConfig).commands)
|
|
107
|
+
) {
|
|
108
|
+
return parsed as EvidenceConfig;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isValidEntry(entry: unknown): entry is EvidenceCommand {
|
|
117
|
+
return (
|
|
118
|
+
!!entry &&
|
|
119
|
+
typeof entry === "object" &&
|
|
120
|
+
typeof (entry as EvidenceCommand).cmd === "string" &&
|
|
121
|
+
(entry as EvidenceCommand).cmd.trim().length > 0
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function effectiveTimeout(entry: EvidenceCommand): number {
|
|
126
|
+
const t = entry.timeout_ms;
|
|
127
|
+
return typeof t === "number" && Number.isFinite(t) && t > 0
|
|
128
|
+
? t
|
|
129
|
+
: DEFAULT_TIMEOUT_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Single command runner ---
|
|
133
|
+
|
|
134
|
+
interface CommandOutcome {
|
|
135
|
+
exit_code: number | null;
|
|
136
|
+
duration_ms: number;
|
|
137
|
+
timedOut: boolean;
|
|
138
|
+
errorNote?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function runCommand(
|
|
142
|
+
cmd: string,
|
|
143
|
+
worktree: string,
|
|
144
|
+
timeoutMs: number,
|
|
145
|
+
): CommandOutcome {
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
try {
|
|
148
|
+
execSync(cmd, {
|
|
149
|
+
cwd: worktree,
|
|
150
|
+
encoding: "utf-8",
|
|
151
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
152
|
+
timeout: timeoutMs,
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
exit_code: 0,
|
|
156
|
+
duration_ms: Date.now() - start,
|
|
157
|
+
timedOut: false,
|
|
158
|
+
};
|
|
159
|
+
} catch (e: unknown) {
|
|
160
|
+
const duration_ms = Date.now() - start;
|
|
161
|
+
if (e && typeof e === "object") {
|
|
162
|
+
const err = e as { signal?: unknown; killed?: unknown; status?: unknown };
|
|
163
|
+
// Signal kill (incl. execSync timeout → SIGTERM) or missing status
|
|
164
|
+
// means the process never exited normally — never message-sniffing:
|
|
165
|
+
// Bun reports timeouts as "spawnSync /bin/sh ETIMEDOUT" with
|
|
166
|
+
// status:null, which contains neither "timeout" nor "SIGTERM".
|
|
167
|
+
if (err.signal != null || err.killed === true || err.status === null) {
|
|
168
|
+
return { exit_code: null, duration_ms, timedOut: true };
|
|
169
|
+
}
|
|
170
|
+
if (typeof err.status === "number") {
|
|
171
|
+
return { exit_code: err.status, duration_ms, timedOut: false };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const msg =
|
|
175
|
+
e && typeof e === "object" && "message" in e
|
|
176
|
+
? String((e as { message?: string }).message ?? "unknown")
|
|
177
|
+
: "unknown";
|
|
178
|
+
return {
|
|
179
|
+
exit_code: -1,
|
|
180
|
+
duration_ms,
|
|
181
|
+
timedOut: false,
|
|
182
|
+
errorNote: msg.slice(0, 200),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function statusFor(outcome: CommandOutcome): {
|
|
188
|
+
status: EvidenceStatus;
|
|
189
|
+
note?: string;
|
|
190
|
+
} {
|
|
191
|
+
if (outcome.timedOut) return { status: "timeout" };
|
|
192
|
+
if (outcome.exit_code === 0) return { status: "passed" };
|
|
193
|
+
return { status: "failed", note: outcome.errorNote };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Main collector ---
|
|
197
|
+
|
|
198
|
+
export interface CollectOptions {
|
|
199
|
+
worktree: string;
|
|
200
|
+
/** Agent-proposed commands — recorded as unverified, never executed. */
|
|
201
|
+
agentCommands?: string[];
|
|
202
|
+
/** Changed files (repo-relative) — infers the app-scoped allowlist. */
|
|
203
|
+
files?: string[];
|
|
204
|
+
/** Config for safetyDeny(); omit for built-in defaults. */
|
|
205
|
+
config?: Config;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collect evidence from allowlisted commands + agent-proposed claims.
|
|
210
|
+
*
|
|
211
|
+
* Execution is sequential. Per-command timeout from config (default 30s),
|
|
212
|
+
* clamped to the remaining share of the 60s total budget so one slow
|
|
213
|
+
* command cannot blow past the cap. Remaining commands after the cap →
|
|
214
|
+
* `timeout`. A command refused by assertSafe() → `failed` with the refusal
|
|
215
|
+
* as note (surfaced, never silent, never crashing the report).
|
|
216
|
+
*/
|
|
217
|
+
export function collectEvidence(options: CollectOptions): EvidenceItem[] {
|
|
218
|
+
const { worktree, agentCommands, files, config } = options;
|
|
219
|
+
const items: EvidenceItem[] = [];
|
|
220
|
+
|
|
221
|
+
const deny = safetyDeny(config);
|
|
222
|
+
const evidencePath = relative(
|
|
223
|
+
worktree,
|
|
224
|
+
resolveEvidencePath(worktree, files ?? [], config),
|
|
225
|
+
);
|
|
226
|
+
const evidenceConfig = readEvidenceConfig(worktree, config, files ?? []);
|
|
227
|
+
const totalStart = Date.now();
|
|
228
|
+
|
|
229
|
+
const totalTimeoutItem = (
|
|
230
|
+
command: string,
|
|
231
|
+
provenance: typeof VERIFIED_PROVENANCE | typeof AGENT_PROVENANCE,
|
|
232
|
+
): EvidenceItem => ({
|
|
233
|
+
command,
|
|
234
|
+
status: "timeout",
|
|
235
|
+
exit_code: null,
|
|
236
|
+
duration_ms: null,
|
|
237
|
+
provenance: { ...provenance },
|
|
238
|
+
note: `total report timeout exceeded ${TOTAL_TIMEOUT_MS}ms`,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const elapsed = () => Date.now() - totalStart;
|
|
242
|
+
|
|
243
|
+
// Run allowlisted commands (verified provenance)
|
|
244
|
+
if (evidenceConfig) {
|
|
245
|
+
for (const entry of evidenceConfig.commands) {
|
|
246
|
+
if (!isValidEntry(entry)) {
|
|
247
|
+
items.push({
|
|
248
|
+
command: String(
|
|
249
|
+
(entry as { name?: unknown } | null)?.name ?? "(invalid entry)",
|
|
250
|
+
),
|
|
251
|
+
status: "not_run",
|
|
252
|
+
exit_code: null,
|
|
253
|
+
duration_ms: null,
|
|
254
|
+
provenance: { ...VERIFIED_PROVENANCE },
|
|
255
|
+
note: `invalid entry in ${evidencePath} (cmd must be a non-empty string)`,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (elapsed() >= TOTAL_TIMEOUT_MS) {
|
|
261
|
+
items.push(totalTimeoutItem(entry.cmd, VERIFIED_PROVENANCE));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Rule #4: every command through assertSafe before spawn — including
|
|
266
|
+
// allowlisted ones (the file lives in agent-reachable worktree).
|
|
267
|
+
try {
|
|
268
|
+
assertSafe([entry.cmd], deny);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
271
|
+
items.push({
|
|
272
|
+
command: entry.cmd,
|
|
273
|
+
status: "failed",
|
|
274
|
+
exit_code: null,
|
|
275
|
+
duration_ms: null,
|
|
276
|
+
provenance: { ...VERIFIED_PROVENANCE },
|
|
277
|
+
note: msg.slice(0, 200),
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Clamp to remaining budget so a single slow command cannot exceed
|
|
283
|
+
// the total cap (execSync blocks; the loop-top check alone is not a
|
|
284
|
+
// hard cap for one long-timeout command).
|
|
285
|
+
const timeoutMs = Math.min(
|
|
286
|
+
effectiveTimeout(entry),
|
|
287
|
+
Math.max(TOTAL_TIMEOUT_MS - elapsed(), 1),
|
|
288
|
+
);
|
|
289
|
+
const outcome = runCommand(entry.cmd, worktree, timeoutMs);
|
|
290
|
+
const { status, note } = statusFor(outcome);
|
|
291
|
+
|
|
292
|
+
items.push({
|
|
293
|
+
command: entry.cmd,
|
|
294
|
+
status,
|
|
295
|
+
exit_code: outcome.exit_code,
|
|
296
|
+
duration_ms: outcome.duration_ms,
|
|
297
|
+
provenance: { ...VERIFIED_PROVENANCE },
|
|
298
|
+
note:
|
|
299
|
+
status === "timeout"
|
|
300
|
+
? `exceeded ${timeoutMs}ms`
|
|
301
|
+
: (note ?? undefined),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Agent-proposed commands: recorded as unverified claims, NEVER executed.
|
|
307
|
+
// `verified:false` means "fapony did not run this" — executing here would
|
|
308
|
+
// both lie in the provenance and hand arbitrary shell to the caller.
|
|
309
|
+
if (agentCommands) {
|
|
310
|
+
for (const cmd of agentCommands) {
|
|
311
|
+
if (typeof cmd !== "string" || cmd.trim().length === 0) continue;
|
|
312
|
+
// Already ran as allowlisted — avoid a duplicate item.
|
|
313
|
+
if (evidenceConfig?.commands.some((e) => e.cmd === cmd)) continue;
|
|
314
|
+
|
|
315
|
+
if (elapsed() >= TOTAL_TIMEOUT_MS) {
|
|
316
|
+
items.push(totalTimeoutItem(cmd, AGENT_PROVENANCE));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
items.push({
|
|
321
|
+
command: cmd,
|
|
322
|
+
status: "unverified",
|
|
323
|
+
exit_code: null,
|
|
324
|
+
duration_ms: null,
|
|
325
|
+
provenance: { ...AGENT_PROVENANCE },
|
|
326
|
+
note: `proposed by agent — not in ${evidencePath} allowlist, not executed`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return items;
|
|
332
|
+
}
|