fapony 0.4.0 → 0.6.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/README.md +201 -431
- package/package.json +1 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/plan-with-pony/SKILL.md +3 -1
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/cli.ts +1 -1
- package/src/adapters/hooks/bug-markers.ts +19 -9
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +32 -6
- package/src/adapters/hooks/edit-hint.ts +11 -1
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +11 -1
- package/src/adapters/hooks/stop.ts +347 -7
- package/src/adapters/mcp/primitives.ts +1 -1
- package/src/adapters/mcp/tools/check.ts +1 -1
- package/src/adapters/mcp/tools/collect.ts +1 -1
- package/src/adapters/mcp/tools/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/tools/report.ts +1 -1
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/analyze/barrels.ts +56 -0
- package/src/analyze/blast.ts +58 -0
- package/src/analyze/cache.ts +162 -0
- package/src/analyze/cli.ts +28 -0
- package/src/analyze/criteria.ts +81 -0
- package/src/analyze/diagnose.ts +113 -0
- package/src/analyze/discover.ts +84 -0
- package/src/analyze/format.ts +38 -0
- package/src/analyze/graph.ts +111 -0
- package/src/analyze/index.ts +18 -0
- package/src/analyze/python.ts +411 -0
- package/src/analyze/resolve-ts.ts +39 -0
- package/src/analyze/types.ts +44 -0
- package/src/conventions-seed.ts +6 -2
- package/src/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/debt/scan.ts +1 -1
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +21 -8
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +6 -0
- package/src/install.ts +6 -4
- package/src/map.ts +220 -0
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +16 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +146 -13
- package/src/seed/review-seed.ts +144 -57
- package/templates/PLAN.md +1 -0
- package/src/analyze.ts +0 -688
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/analyze/types.ts — shared types for the analyze graph + diagnosis.
|
|
2
|
+
//
|
|
3
|
+
// Pure types only, no imports — every analyze module depends on this one,
|
|
4
|
+
// nothing here depends back (same shape as src/install/types.ts).
|
|
5
|
+
|
|
6
|
+
export interface ImportGraph {
|
|
7
|
+
/** Relative POSIX paths scanned. */
|
|
8
|
+
files: string[];
|
|
9
|
+
/** file → files it imports. */
|
|
10
|
+
deps: Map<string, Set<string>>;
|
|
11
|
+
/** file → files that import it (reverse). */
|
|
12
|
+
dependents: Map<string, Set<string>>;
|
|
13
|
+
/** Imports that could not be resolved (bare specifier / alias / builtin). */
|
|
14
|
+
/** Imports that may hide a real edge: relative misses, path aliases, bare
|
|
15
|
+
* package names, and files too broken to read. Worth warning about. */
|
|
16
|
+
unresolved: number;
|
|
17
|
+
/** `node:` / `bun:` builtins — never an edge between two project files, so
|
|
18
|
+
* counting them as "unresolved" only inflates the warning. */
|
|
19
|
+
external: number;
|
|
20
|
+
/** Files that only re-export (`export ... from`) — they hide the real importer. */
|
|
21
|
+
barrels: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FindingKind =
|
|
25
|
+
| "hub-untested"
|
|
26
|
+
| "orphan"
|
|
27
|
+
| "cycle"
|
|
28
|
+
| "changed-untested";
|
|
29
|
+
|
|
30
|
+
export interface Finding {
|
|
31
|
+
kind: FindingKind;
|
|
32
|
+
file: string;
|
|
33
|
+
/** Human sentence — the thing people read. */
|
|
34
|
+
detail: string;
|
|
35
|
+
/** How to re-check it yourself (file names, cycle path). */
|
|
36
|
+
evidence: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BlastEntry {
|
|
40
|
+
dependents: number;
|
|
41
|
+
tested: boolean;
|
|
42
|
+
/** Dependents of dependents, transitively (cycle-safe, excludes the file itself). */
|
|
43
|
+
transitive: number;
|
|
44
|
+
}
|
package/src/conventions-seed.ts
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join, relative } from "node:path";
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
collectSourceFiles,
|
|
19
|
+
isSkippedDir,
|
|
20
|
+
isTestFile,
|
|
21
|
+
} from "./analyze/index.js";
|
|
18
22
|
import { CONVENTIONS_FILE, FAPONY_DIR } from "./core/config.js";
|
|
19
23
|
import { extractBody, extractExports } from "./map.js";
|
|
20
24
|
|
|
@@ -300,7 +304,7 @@ function detectWrappers(root: string): SeedRow[] {
|
|
|
300
304
|
} catch {
|
|
301
305
|
continue;
|
|
302
306
|
}
|
|
303
|
-
const scan = extractExports(source);
|
|
307
|
+
const scan = extractExports(source, undefined, rel);
|
|
304
308
|
if (scan.error) continue;
|
|
305
309
|
for (const sym of scan.symbols) {
|
|
306
310
|
if (sym.kind !== "const" && sym.kind !== "fn") continue;
|
package/src/core/hint-log.ts
CHANGED
|
@@ -30,7 +30,17 @@ export function hintLogPath(worktree: string): string {
|
|
|
30
30
|
export interface HintFireRow {
|
|
31
31
|
ts: string;
|
|
32
32
|
worktree: string;
|
|
33
|
-
surface:
|
|
33
|
+
surface:
|
|
34
|
+
| "read"
|
|
35
|
+
| "debt"
|
|
36
|
+
| "mem"
|
|
37
|
+
| "commit"
|
|
38
|
+
| "edit"
|
|
39
|
+
| "open-bug"
|
|
40
|
+
| "handoff-would-block"
|
|
41
|
+
| "handoff-pass"
|
|
42
|
+
| "commit-block"
|
|
43
|
+
| "bug-block";
|
|
34
44
|
file: string | null;
|
|
35
45
|
count: number;
|
|
36
46
|
ids?: string[];
|
|
@@ -62,6 +72,11 @@ export interface HintImpact {
|
|
|
62
72
|
mem: number;
|
|
63
73
|
commit: number;
|
|
64
74
|
edit: number;
|
|
75
|
+
"open-bug": number;
|
|
76
|
+
"handoff-would-block": number;
|
|
77
|
+
"handoff-pass": number;
|
|
78
|
+
"commit-block": number;
|
|
79
|
+
"bug-block": number;
|
|
65
80
|
};
|
|
66
81
|
debt: { shown: number; resolved: number; unknown: number };
|
|
67
82
|
window: string | null;
|
package/src/core/mem-log.ts
CHANGED
|
@@ -22,8 +22,22 @@ export interface MemRow {
|
|
|
22
22
|
ref?: string;
|
|
23
23
|
/** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
|
|
24
24
|
files?: string[];
|
|
25
|
+
/** Problem identity, distinct from kind (action) and files[] (place) — written by `mem add --key` (PLAN-mem-keys chunk 1). */
|
|
26
|
+
key?: string;
|
|
27
|
+
/** Row schema version: 2 = may carry `key`; absent = v:1 legacy row, read as-is. */
|
|
28
|
+
v?: number;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** One mem-row text budget for any hint/seed surface that shows a row (was context-data's private const). */
|
|
32
|
+
export const MEM_TEXT_MAX = 120;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The only accepted shape of a mem row `key` — problem identity, not action
|
|
36
|
+
* (kind) and not place (files[]). Lives here in core so CLI and MCP validate
|
|
37
|
+
* through one regex (PLAN-unify-mem-engine: one engine, both surfaces).
|
|
38
|
+
*/
|
|
39
|
+
export const KEY_RE = /^[a-z0-9-]{3,40}$/;
|
|
40
|
+
|
|
27
41
|
interface RawMemRow {
|
|
28
42
|
ts?: string;
|
|
29
43
|
agent?: string;
|
|
@@ -33,6 +47,8 @@ interface RawMemRow {
|
|
|
33
47
|
id?: string;
|
|
34
48
|
ref?: string;
|
|
35
49
|
files?: unknown;
|
|
50
|
+
key?: unknown;
|
|
51
|
+
v?: unknown;
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
/**
|
|
@@ -319,6 +335,8 @@ export function readMemLog(
|
|
|
319
335
|
...(Array.isArray(parsed.files)
|
|
320
336
|
? { files: parsed.files.filter((f) => typeof f === "string") }
|
|
321
337
|
: {}),
|
|
338
|
+
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
|
|
339
|
+
...(typeof parsed.v === "number" ? { v: parsed.v } : {}),
|
|
322
340
|
});
|
|
323
341
|
}
|
|
324
342
|
}
|
package/src/debt/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { ZONE_CAP } from "./types.js";
|
|
|
13
13
|
|
|
14
14
|
const USAGE = `usage: fapony debt [path] [options]
|
|
15
15
|
--files f1,f2 check specific files instead of scanning
|
|
16
|
-
--id
|
|
16
|
+
--id a,b show only these conventions
|
|
17
17
|
--where <path> narrow scope to files under this path
|
|
18
18
|
--all show all zones (default: cap at ${ZONE_CAP})
|
|
19
19
|
--json output raw JSON
|
|
@@ -68,7 +68,7 @@ export function cmdDebt(args: string[]): void {
|
|
|
68
68
|
let path: string | undefined;
|
|
69
69
|
let filesMode: string[] | null = null;
|
|
70
70
|
let json = false;
|
|
71
|
-
let
|
|
71
|
+
let filterIds: string[] = [];
|
|
72
72
|
let wherePath: string | undefined;
|
|
73
73
|
let showAll = false;
|
|
74
74
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -80,14 +80,17 @@ export function cmdDebt(args: string[]): void {
|
|
|
80
80
|
process.exit(1);
|
|
81
81
|
}
|
|
82
82
|
i++;
|
|
83
|
-
|
|
83
|
+
// Accumulate, never reassign: a repeated --files grows the set
|
|
84
|
+
// (PLAN-comma-x chunk 2 — last-wins was silent data loss).
|
|
85
|
+
const parts = v
|
|
84
86
|
.split(",")
|
|
85
87
|
.map((s) => s.trim())
|
|
86
88
|
.filter(Boolean);
|
|
87
|
-
if (
|
|
89
|
+
if (parts.length === 0) {
|
|
88
90
|
console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
|
|
89
91
|
process.exit(1);
|
|
90
92
|
}
|
|
93
|
+
filesMode = [...(filesMode ?? []), ...parts];
|
|
91
94
|
} else if (a === "--id") {
|
|
92
95
|
const v = args[i + 1];
|
|
93
96
|
if (!v || v.startsWith("--")) {
|
|
@@ -95,7 +98,17 @@ export function cmdDebt(args: string[]): void {
|
|
|
95
98
|
process.exit(1);
|
|
96
99
|
}
|
|
97
100
|
i++;
|
|
98
|
-
|
|
101
|
+
// Comma list, same shape as --files: ids are slugs, an id can't hold
|
|
102
|
+
// a comma, so any comma splits (PLAN-comma-x). Repeats accumulate too.
|
|
103
|
+
const parts = v
|
|
104
|
+
.split(",")
|
|
105
|
+
.map((s) => s.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
if (parts.length === 0) {
|
|
108
|
+
console.error(`fapony debt: --id needs a convention id\n${USAGE}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
filterIds = [...filterIds, ...parts];
|
|
99
112
|
} else if (a === "--where") {
|
|
100
113
|
const v = args[i + 1];
|
|
101
114
|
if (!v || v.startsWith("--")) {
|
|
@@ -171,12 +184,14 @@ export function cmdDebt(args: string[]): void {
|
|
|
171
184
|
}
|
|
172
185
|
const report = debtScan(worktree, loaded);
|
|
173
186
|
|
|
174
|
-
// --id filter: keep only the named
|
|
175
|
-
if (
|
|
176
|
-
report.entries = report.entries.filter((e) =>
|
|
177
|
-
|
|
187
|
+
// --id filter: keep only the named conventions (comma list allowed)
|
|
188
|
+
if (filterIds.length > 0) {
|
|
189
|
+
report.entries = report.entries.filter((e) =>
|
|
190
|
+
filterIds.includes(e.conv.id),
|
|
191
|
+
);
|
|
192
|
+
report.declared = report.declared.filter((c) => filterIds.includes(c.id));
|
|
178
193
|
report.checkedCount = 0; // not relevant when filtering
|
|
179
|
-
report.dropped = report.dropped.filter((d) => d.id
|
|
194
|
+
report.dropped = report.dropped.filter((d) => filterIds.includes(d.id));
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
// --where filter: narrow file lists to paths under the given prefix
|
package/src/debt/scan.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { isAbsolute, join, relative } from "node:path";
|
|
8
|
-
import { collectSourceFiles } from "../analyze.js";
|
|
8
|
+
import { collectSourceFiles } from "../analyze/index.js";
|
|
9
9
|
import {
|
|
10
10
|
type Convention,
|
|
11
11
|
DEBT_FILE_CAP,
|
package/src/hook.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
BUG_MARKERS,
|
|
10
|
+
bugDedupeKey,
|
|
10
11
|
bugSignalFromTranscript,
|
|
11
12
|
COMMIT_HINT_MIN_COMMITS,
|
|
12
13
|
type CommitHintInput,
|
|
@@ -19,12 +20,15 @@ export {
|
|
|
19
20
|
cmdHookStop,
|
|
20
21
|
commitHintFor,
|
|
21
22
|
computeHintImpact,
|
|
23
|
+
countTicks,
|
|
22
24
|
cursorTranscriptPath,
|
|
25
|
+
decideHandoff,
|
|
23
26
|
decideStop,
|
|
24
27
|
type EditHintInput,
|
|
25
28
|
type EditTrackRow,
|
|
26
29
|
editHintFor,
|
|
27
30
|
editTrackPath,
|
|
31
|
+
fileAtRevision,
|
|
28
32
|
GIT_AUTONOMY_COMMIT_POLICY,
|
|
29
33
|
GIT_AUTONOMY_PLUGIN_FILE,
|
|
30
34
|
GIT_AUTONOMY_PLUGIN_NAME,
|
|
@@ -35,15 +39,24 @@ export {
|
|
|
35
39
|
type GitAutonomyStatus,
|
|
36
40
|
type GitAutonomyToolRewrite,
|
|
37
41
|
gitAutonomyStatus,
|
|
42
|
+
type HandoffDecision,
|
|
43
|
+
type HandoffPlanFile,
|
|
38
44
|
type HintFireRow,
|
|
39
45
|
type HintImpact,
|
|
46
|
+
handoffBaseSha,
|
|
47
|
+
handoffBlockMessage,
|
|
48
|
+
handoffDedupeKey,
|
|
40
49
|
hasBugMarker,
|
|
50
|
+
hasHandoffLiteral,
|
|
41
51
|
hintLogDir,
|
|
42
52
|
hintLogPath,
|
|
43
53
|
hookTsMs,
|
|
44
54
|
isBugfixCommit,
|
|
45
55
|
isCodexPayload,
|
|
46
56
|
isCursorPayload,
|
|
57
|
+
isPlanPath,
|
|
58
|
+
memHasHandoffForPlan,
|
|
59
|
+
mergeStopReasons,
|
|
47
60
|
mvGuardDecision,
|
|
48
61
|
type NormalizedStopInput,
|
|
49
62
|
normalizeStopInput,
|
|
@@ -64,9 +77,11 @@ export {
|
|
|
64
77
|
SESSION_START_MAX_CHARS,
|
|
65
78
|
type StopClient,
|
|
66
79
|
sessionKey,
|
|
80
|
+
sessionPlanFiles,
|
|
67
81
|
sessionStartContext,
|
|
68
82
|
stopBlockedBefore,
|
|
69
83
|
stopBlockPath,
|
|
84
|
+
stopBlockSurface,
|
|
70
85
|
stopOutput,
|
|
71
86
|
utcStamp,
|
|
72
87
|
worktreeKey,
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// Skills path). No hooks in the first phase — Antigravity's hook surface is
|
|
6
6
|
// still evolving.
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { defaultCheckCmd } from "../setup.js";
|
|
11
12
|
import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
12
13
|
import {
|
|
13
14
|
CURSOR_MCP_ENTRY,
|
|
@@ -33,7 +34,9 @@ function isFaponyEntry(entry: unknown): boolean {
|
|
|
33
34
|
|
|
34
35
|
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
+
const raw = readFileSync(path, "utf-8").trim();
|
|
38
|
+
if (!raw) return {};
|
|
39
|
+
const parsed: unknown = JSON.parse(raw);
|
|
37
40
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
38
41
|
return null;
|
|
39
42
|
}
|
|
@@ -49,14 +52,20 @@ export function cmdInstallAntigravity(
|
|
|
49
52
|
): void {
|
|
50
53
|
const exitFn = deps.exit ?? defaultExit;
|
|
51
54
|
const getHome = deps.homedir ?? homedir;
|
|
52
|
-
const
|
|
55
|
+
const checkCmd = deps.checkCmd ?? defaultCheckCmd;
|
|
56
|
+
let geminiDir = findGeminiDir(getHome);
|
|
53
57
|
|
|
54
58
|
if (!geminiDir) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
// Detect signal #2: the `agy` CLI on PATH — installed but never launched,
|
|
60
|
+
// so ~/.gemini doesn't exist yet; the write path below creates it.
|
|
61
|
+
if (!checkCmd("agy")) {
|
|
62
|
+
console.error(
|
|
63
|
+
`Antigravity not found — open Antigravity at least once to create ~/.gemini (or put the agy CLI on PATH)`,
|
|
64
|
+
);
|
|
65
|
+
exitFn(1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
geminiDir = join(getHome(), ".gemini");
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
// --- 1. MCP server (mcpServers.fapony in ~/.gemini/config/mcp_config.json) ---
|
|
@@ -99,6 +108,10 @@ export function cmdInstallAntigravity(
|
|
|
99
108
|
`── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
|
|
100
109
|
);
|
|
101
110
|
} else {
|
|
111
|
+
// Escape hatch (plan §5): ~/.gemini/config may not exist yet — either
|
|
112
|
+
// the app never got as far as its config dir, or agy-on-PATH created
|
|
113
|
+
// nothing. Recursive mkdir is idempotent and non-destructive.
|
|
114
|
+
mkdirSync(configDir, { recursive: true });
|
|
102
115
|
writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
|
|
103
116
|
console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
|
|
104
117
|
console.error(` restart Antigravity to load the MCP server`);
|
package/src/install/detect.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/install/detect.ts — detect which MCP clients are installed on this machine.
|
|
2
2
|
//
|
|
3
3
|
// Signal per client:
|
|
4
|
-
// antigravity = ~/.gemini exists (the app creates it on first run)
|
|
4
|
+
// antigravity = ~/.gemini exists (the app creates it on first run) OR `agy` on PATH
|
|
5
5
|
// claude = `command -v claude` (CLI on PATH)
|
|
6
6
|
// cursor = ~/.cursor exists (the app creates it on first run)
|
|
7
7
|
// opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
|
|
@@ -28,14 +28,15 @@ export interface DetectedClient {
|
|
|
28
28
|
* Returns one entry per platform, ordered: antigravity, claude, cursor, opencode, zcode, codex.
|
|
29
29
|
*
|
|
30
30
|
* Uses resolvers from each provider (file/dir-exists check) for
|
|
31
|
-
* antigravity/cursor/opencode/zcode/codex, and `command -v claude
|
|
32
|
-
* through injected deps for testability.
|
|
31
|
+
* antigravity/cursor/opencode/zcode/codex, and `command -v claude`/`command -v agy`
|
|
32
|
+
* for claude/antigravity — all through injected deps for testability.
|
|
33
33
|
*/
|
|
34
34
|
export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
35
35
|
const getHome = deps.homedir ?? homedir;
|
|
36
36
|
const checkCmd = deps.checkCmd ?? defaultCheckCmd;
|
|
37
37
|
|
|
38
38
|
const claude = checkCmd("claude");
|
|
39
|
+
const agy = checkCmd("agy");
|
|
39
40
|
|
|
40
41
|
const geminiDir = findGeminiDir(getHome);
|
|
41
42
|
const cursorDir = findCursorDir(getHome);
|
|
@@ -46,10 +47,12 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
|
46
47
|
return [
|
|
47
48
|
{
|
|
48
49
|
platform: "antigravity",
|
|
49
|
-
installed: geminiDir !== null,
|
|
50
|
+
installed: geminiDir !== null || agy,
|
|
50
51
|
why: geminiDir
|
|
51
52
|
? `dir at ${geminiDir}`
|
|
52
|
-
:
|
|
53
|
+
: agy
|
|
54
|
+
? "agy CLI on PATH"
|
|
55
|
+
: "no ~/.gemini directory (open Antigravity once)",
|
|
53
56
|
},
|
|
54
57
|
{
|
|
55
58
|
platform: "claude",
|
package/src/install/opencode.ts
CHANGED
|
@@ -271,6 +271,9 @@ export const FaponyReadHint = async ({ directory }) => {
|
|
|
271
271
|
if (ctx.memLines.length > 0) {
|
|
272
272
|
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
|
|
273
273
|
}
|
|
274
|
+
if (ctx.openBugIds.length > 0) {
|
|
275
|
+
recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
|
|
276
|
+
}
|
|
274
277
|
}
|
|
275
278
|
} catch {
|
|
276
279
|
// a hint must never break a read
|
|
@@ -412,6 +415,9 @@ export const FaponyEditHint = async ({ directory }) => {
|
|
|
412
415
|
if (ctx?.memLines?.length) {
|
|
413
416
|
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
|
|
414
417
|
}
|
|
418
|
+
if (ctx?.openBugIds?.length) {
|
|
419
|
+
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
|
|
420
|
+
}
|
|
415
421
|
}
|
|
416
422
|
} catch {
|
|
417
423
|
// best-effort
|
package/src/install.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/install.ts — `fapony install --platform opencode|claude|cursor|zcode|codex` command.
|
|
1
|
+
// src/install.ts — `fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex` command.
|
|
2
2
|
// opencode: adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
|
|
3
3
|
// claude: shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
|
|
4
4
|
// cursor: writes mcpServers.fapony to ~/.cursor/mcp.json directly and merges the
|
|
@@ -59,7 +59,9 @@ export async function cmdInstall(
|
|
|
59
59
|
args: string[],
|
|
60
60
|
deps: InstallDeps = {},
|
|
61
61
|
): Promise<void> {
|
|
62
|
-
const
|
|
62
|
+
const platformArg = args.find((a) => !a.startsWith("--"));
|
|
63
|
+
// `agy` = Antigravity's CLI name — alias, same provider.
|
|
64
|
+
const platform = platformArg === "agy" ? "antigravity" : platformArg;
|
|
63
65
|
const dryRun = args.includes("--dry-run");
|
|
64
66
|
const installAll = args.includes("--all");
|
|
65
67
|
// Opt-in only: the git-autonomy rewrite is an opinion (commit-as-you-go),
|
|
@@ -96,10 +98,10 @@ export async function cmdInstall(
|
|
|
96
98
|
}
|
|
97
99
|
if (platform !== undefined) {
|
|
98
100
|
console.error(
|
|
99
|
-
`usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
|
|
101
|
+
`usage: fapony install --platform antigravity|agy|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
|
|
100
102
|
);
|
|
101
103
|
console.error(
|
|
102
|
-
` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
|
|
104
|
+
` supported platforms: antigravity (agy), opencode, claude, cursor, zcode, codex`,
|
|
103
105
|
);
|
|
104
106
|
(deps.exit ?? defaultExit)(1);
|
|
105
107
|
return;
|
package/src/map.ts
CHANGED
|
@@ -167,10 +167,230 @@ function braceNames(
|
|
|
167
167
|
|
|
168
168
|
const VAR_DECL_RE = /^export\s+(?:const|let|var)\b/;
|
|
169
169
|
|
|
170
|
+
// --- Python exports (no parser: Bun.Transpiler can't read .py, and shelling
|
|
171
|
+
// out to `python -c "import ast"` was cut — a subprocess per file blows the
|
|
172
|
+
// measured budgets (buildGraph ~50ms/150 files, review-seed 0.31–0.51s
|
|
173
|
+
// uncached) and breaks the Bun-only constraint. Line-based, top level only.)
|
|
174
|
+
|
|
175
|
+
const PY_DEF_RE = /^(?:async\s+)?def\s+([A-Za-z_]\w*)/;
|
|
176
|
+
const PY_CLASS_RE = /^class\s+([A-Za-z_]\w*)/;
|
|
177
|
+
// `x = …` and `x: T = …` — `=(?!=)` keeps `==`/`!=`/`>=` comparisons out.
|
|
178
|
+
const PY_ASSIGN_RE = /^([A-Za-z_]\w*)\s*(?::\s*[^=;#]+?)?=(?!=)/;
|
|
179
|
+
const PY_FROM_RE = /^from\s+(\S+)\s+import\s+(.+)$/;
|
|
180
|
+
const PY_ALL_RE = /^__all__\s*=/;
|
|
181
|
+
|
|
182
|
+
// Walk one line tracking quote state; returns the code before a `#` comment.
|
|
183
|
+
// Triple quotes are handled for the single-line case (`x = """a#b"""`) —
|
|
184
|
+
// multi-line strings are skipped by the block tracker below, not here.
|
|
185
|
+
function stripPyComment(line: string): string {
|
|
186
|
+
let quote: string | null = null;
|
|
187
|
+
for (let i = 0; i < line.length; i++) {
|
|
188
|
+
const c = line[i];
|
|
189
|
+
if (quote) {
|
|
190
|
+
if (c === "\\") {
|
|
191
|
+
i++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (line.startsWith(quote, i)) {
|
|
195
|
+
i += quote.length - 1;
|
|
196
|
+
quote = null;
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (c === "#") return line.slice(0, i);
|
|
201
|
+
if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
|
|
202
|
+
quote = line.slice(i, i + 3);
|
|
203
|
+
i += 2;
|
|
204
|
+
} else if (c === '"' || c === "'") {
|
|
205
|
+
quote = c;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return line;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Blank out triple-quoted string blocks, preserving the line count, so a
|
|
212
|
+
// line-based scan never reads code (`def`, `from .x import y`) out of a
|
|
213
|
+
// docstring or a multi-line string. Code before an opening `"""` on the same
|
|
214
|
+
// line is kept; the block itself and its closing line become empty. Single
|
|
215
|
+
// source of truth for both the export scan (below) and analyze's import scan.
|
|
216
|
+
// First triple quote that is NOT inside a single/double-quoted string on this
|
|
217
|
+
// line — so `x = 'has """ inside'` never opens a block. `stripPyComment` walks
|
|
218
|
+
// the same quote state; this reports where a block actually starts.
|
|
219
|
+
function unquotedTriple(code: string): { at: number; q: string } | null {
|
|
220
|
+
let quote: string | null = null;
|
|
221
|
+
for (let i = 0; i < code.length; i++) {
|
|
222
|
+
const c = code[i];
|
|
223
|
+
if (quote) {
|
|
224
|
+
if (c === "\\") {
|
|
225
|
+
i++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (code.startsWith(quote, i)) {
|
|
229
|
+
i += quote.length - 1;
|
|
230
|
+
quote = null;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (code.startsWith('"""', i) || code.startsWith("'''", i))
|
|
235
|
+
return { at: i, q: code.slice(i, i + 3) };
|
|
236
|
+
if (c === '"' || c === "'") quote = c;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function maskPyBlocks(source: string): string {
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
let block: string | null = null;
|
|
244
|
+
for (const raw of source.split("\n")) {
|
|
245
|
+
if (block) {
|
|
246
|
+
const end = raw.indexOf(block);
|
|
247
|
+
if (end < 0) {
|
|
248
|
+
out.push("");
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
block = null;
|
|
252
|
+
out.push(raw.slice(end + 3));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const code = stripPyComment(raw);
|
|
256
|
+
const triple = unquotedTriple(code);
|
|
257
|
+
if (triple) {
|
|
258
|
+
const { at, q } = triple;
|
|
259
|
+
if (code.indexOf(q, at + 3) < 0) {
|
|
260
|
+
block = q;
|
|
261
|
+
out.push(code.slice(0, at));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
out.push(raw);
|
|
266
|
+
}
|
|
267
|
+
return out.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// String literals inside an `__all__ = [...]` (or `(...)`) assignment,
|
|
271
|
+
// possibly spanning lines. Anything dynamic (`append`, `+=`, a variable)
|
|
272
|
+
// yields nothing — the caller falls back to every top-level name.
|
|
273
|
+
function pyAllNames(text: string): string[] {
|
|
274
|
+
const open =
|
|
275
|
+
text.indexOf("[") >= 0 ? "[" : text.indexOf("(") >= 0 ? "(" : null;
|
|
276
|
+
if (!open) return [];
|
|
277
|
+
const close = open === "[" ? "]" : ")";
|
|
278
|
+
const body = text.slice(text.indexOf(open) + 1);
|
|
279
|
+
if (!body.includes(close)) return [];
|
|
280
|
+
const out: string[] = [];
|
|
281
|
+
for (const m of body.matchAll(/["']([A-Za-z_]\w*)["']/g)) out.push(m[1]);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pyFromNames(rest: string): string[] {
|
|
286
|
+
const clean = rest.replace(/[()]/g, " ");
|
|
287
|
+
const out: string[] = [];
|
|
288
|
+
for (let part of clean.split(",")) {
|
|
289
|
+
part = part.trim().split("#")[0].trim();
|
|
290
|
+
if (!part || part === "*") continue;
|
|
291
|
+
const as = part.split(/\s+as\s+/);
|
|
292
|
+
const name = as[as.length - 1].trim();
|
|
293
|
+
if (/^[A-Za-z_]\w*$/.test(name)) out.push(name);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function extractPythonExports(source: string): ExportScan {
|
|
299
|
+
// Blank docstrings/strings first, preserving line numbers — a `def` inside
|
|
300
|
+
// a docstring is sample text, the same blind spot the TS path closes with
|
|
301
|
+
// the parse gate.
|
|
302
|
+
const lines = maskPyBlocks(source).split("\n");
|
|
303
|
+
const found = new Map<string, { line: number; kind: ExportKind }>();
|
|
304
|
+
let allNames: string[] = [];
|
|
305
|
+
let allLine = 0;
|
|
306
|
+
|
|
307
|
+
const remember = (name: string, line: number, kind: ExportKind): void => {
|
|
308
|
+
if (!found.has(name)) found.set(name, { line, kind });
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < lines.length; i++) {
|
|
312
|
+
const raw = lines[i];
|
|
313
|
+
const code = stripPyComment(raw).trimEnd();
|
|
314
|
+
if (code === "") continue;
|
|
315
|
+
if (/^\s/.test(raw)) continue; // indented — not top level
|
|
316
|
+
const t = code.trim();
|
|
317
|
+
let m: RegExpMatchArray | null;
|
|
318
|
+
if ((m = t.match(PY_DEF_RE))) {
|
|
319
|
+
remember(m[1], i + 1, "fn");
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if ((m = t.match(PY_CLASS_RE))) {
|
|
323
|
+
remember(m[1], i + 1, "class");
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (PY_ALL_RE.test(t)) {
|
|
327
|
+
// `__all__` may span lines — join until the bracket closes (cap 20).
|
|
328
|
+
let joined = t;
|
|
329
|
+
let j = i;
|
|
330
|
+
while (!/[\])]/.test(joined) && j + 1 < lines.length && j - i < 20) {
|
|
331
|
+
j++;
|
|
332
|
+
joined += ` ${stripPyComment(lines[j]).trim()}`;
|
|
333
|
+
}
|
|
334
|
+
allNames = pyAllNames(joined);
|
|
335
|
+
allLine = i + 1;
|
|
336
|
+
i = j;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if ((m = t.match(PY_FROM_RE))) {
|
|
340
|
+
// Parenthesized lists often span lines — join until they close (cap 20),
|
|
341
|
+
// the same shape as the TS brace-block join above.
|
|
342
|
+
let rest = m[2];
|
|
343
|
+
let j = i;
|
|
344
|
+
const unbalanced = (s: string): boolean =>
|
|
345
|
+
(s.match(/\(/g) ?? []).length > (s.match(/\)/g) ?? []).length;
|
|
346
|
+
while (unbalanced(rest) && j + 1 < lines.length && j - i < 20) {
|
|
347
|
+
j++;
|
|
348
|
+
rest += ` ${stripPyComment(lines[j]).trim()}`;
|
|
349
|
+
}
|
|
350
|
+
for (const name of pyFromNames(rest)) remember(name, i + 1, "re-export");
|
|
351
|
+
i = j;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (/^import\s+/.test(t)) continue; // graph data, not an export
|
|
355
|
+
if ((m = t.match(PY_ASSIGN_RE))) {
|
|
356
|
+
if (m[1] === "__all__") continue;
|
|
357
|
+
remember(m[1], i + 1, "const");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (allNames.length > 0) {
|
|
362
|
+
// `__all__` is authoritative: undocumented underscore names listed there
|
|
363
|
+
// are public, and anything not listed is not — including real defs.
|
|
364
|
+
const out: ExportSymbol[] = [];
|
|
365
|
+
const seen = new Set<string>();
|
|
366
|
+
for (const name of allNames) {
|
|
367
|
+
if (seen.has(name)) continue;
|
|
368
|
+
seen.add(name);
|
|
369
|
+
const f = found.get(name);
|
|
370
|
+
out.push(
|
|
371
|
+
f
|
|
372
|
+
? { name, line: f.line, kind: f.kind }
|
|
373
|
+
: { name, line: allLine, kind: "re-export" },
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return { symbols: out, error: null };
|
|
377
|
+
}
|
|
378
|
+
// No `__all__`: underscore-prefixed names are private by convention.
|
|
379
|
+
return {
|
|
380
|
+
symbols: [...found]
|
|
381
|
+
.filter(([name]) => !name.startsWith("_"))
|
|
382
|
+
.map(([name, f]) => ({ name, line: f.line, kind: f.kind })),
|
|
383
|
+
error: null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
170
387
|
export function extractExports(
|
|
171
388
|
source: string,
|
|
172
389
|
scanner?: ExportScanner,
|
|
390
|
+
filename?: string,
|
|
173
391
|
): ExportScan {
|
|
392
|
+
if (filename?.endsWith(".py") || filename?.endsWith(".pyi"))
|
|
393
|
+
return extractPythonExports(source);
|
|
174
394
|
const s = scanner ?? getDefaultScanner();
|
|
175
395
|
const scanned = scanSource(source, s);
|
|
176
396
|
if (scanned.error) return { symbols: [], error: scanned.error };
|