fapony 0.3.6 → 0.5.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.
@@ -23,6 +23,8 @@ export interface MemFindResult {
23
23
  filesFound: number;
24
24
  skipped: number;
25
25
  memDir: string | null;
26
+ /** Present only when a key query missed — every distinct key in the log. */
27
+ knownKeys?: string[];
26
28
  }
27
29
 
28
30
  export function memFind(args: {
@@ -33,6 +35,7 @@ export function memFind(args: {
33
35
  since?: string;
34
36
  limit?: number;
35
37
  open?: boolean;
38
+ key?: string;
36
39
  }): MemFindResult {
37
40
  // Query logic lives in the shared engine (src/mem/engine.ts) — this wrapper
38
41
  // owns only the read (readMemLog sees live + rotated archives via its loose
@@ -40,22 +43,24 @@ export function memFind(args: {
40
43
  // omitting kind returns every kind (locked by test). CLI cmdFind calls the
41
44
  // same engine with its own bookkeeping exclude.
42
45
  const read = readMemLog(args.worktree);
43
- const { rows: matched, total } = engineFind(read.rows, {
46
+ const found = engineFind(read.rows, {
44
47
  text: args.text,
45
48
  files: args.files,
46
49
  kind: args.kind,
47
50
  sinceIso: args.since,
51
+ key: args.key,
48
52
  limit: args.limit,
49
53
  open: args.open,
50
54
  });
51
55
  return {
52
- rows: matched,
53
- total,
56
+ rows: found.rows,
57
+ total: found.total,
54
58
  filesFound: read.filesFound,
55
59
  skipped: read.skipped,
56
60
  // memDir separates "no mem at all" from "nothing matched" (spec §3) and
57
61
  // makes the monorepo single-log limit visible (spec §5.1).
58
62
  memDir: resolveMemDir(args.worktree),
63
+ ...(found.knownKeys ? { knownKeys: found.knownKeys } : {}),
59
64
  };
60
65
  }
61
66
 
@@ -93,9 +98,18 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
93
98
  }
94
99
  const limit = typeof args.limit === "number" ? args.limit : undefined;
95
100
  const open = typeof args.open === "boolean" ? args.open : undefined;
101
+ // Shape gate like mem_add — a non-string key must not coerce to undefined
102
+ // (silent drop = reject-without-saying). Pattern is NOT checked here: find
103
+ // answers a wrong-pattern key with knownKeys, not a reject (SPEC fail example).
104
+ if (args.key !== undefined && typeof args.key !== "string") {
105
+ return errorResult(
106
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
107
+ );
108
+ }
109
+ const key = typeof args.key === "string" ? args.key : undefined;
96
110
 
97
111
  return jsonResult(
98
- memFind({ worktree, files, text, kind, since, limit, open }),
112
+ memFind({ worktree, files, text, kind, since, limit, open, key }),
99
113
  );
100
114
  }
101
115
 
@@ -112,6 +126,7 @@ export function memAdd(args: {
112
126
  text: string;
113
127
  files: string[];
114
128
  spec?: string;
129
+ key?: string;
115
130
  }): MemAddResult {
116
131
  initStore(args.worktree);
117
132
  return engineAdd({
@@ -119,6 +134,7 @@ export function memAdd(args: {
119
134
  text: args.text,
120
135
  files: args.files,
121
136
  spec: args.spec,
137
+ key: args.key,
122
138
  });
123
139
  }
124
140
 
@@ -166,8 +182,17 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
166
182
  ? args.spec.trim()
167
183
  : undefined;
168
184
 
185
+ // Shape gate here, pattern check in engineAdd — a non-string key must not
186
+ // slip through as undefined (silent drop = reject-without-saying, SPEC §Validation).
187
+ if (args.key !== undefined && typeof args.key !== "string") {
188
+ return errorResult(
189
+ 'key must be a string matching [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe"',
190
+ );
191
+ }
192
+ const key = typeof args.key === "string" ? args.key : undefined;
193
+
169
194
  try {
170
- const result = memAdd({ worktree, kind, text, files, spec });
195
+ const result = memAdd({ worktree, kind, text, files, spec, key });
171
196
  return jsonResult(result);
172
197
  } catch (e) {
173
198
  return errorResult(e instanceof Error ? e.message : String(e));
@@ -19,7 +19,7 @@ import { errorResult, type ToolResult } from "./types.js";
19
19
 
20
20
  const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
21
21
 
22
- When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
22
+ When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session. Name a known problem with --key <a-z0-9-id> and recall every row for it via mem_find key.
23
23
 
24
24
  worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
25
25
 
@@ -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: "read" | "debt" | "mem" | "commit" | "edit";
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;
@@ -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 <conv> show only this convention
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 filterId: string | undefined;
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
- filesMode = v
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 (filesMode.length === 0) {
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
- filterId = v;
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 convention
175
- if (filterId) {
176
- report.entries = report.entries.filter((e) => e.conv.id === filterId);
177
- report.declared = report.declared.filter((c) => c.id === filterId);
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 === filterId);
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/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,
@@ -49,14 +50,20 @@ export function cmdInstallAntigravity(
49
50
  ): void {
50
51
  const exitFn = deps.exit ?? defaultExit;
51
52
  const getHome = deps.homedir ?? homedir;
52
- const geminiDir = findGeminiDir(getHome);
53
+ const checkCmd = deps.checkCmd ?? defaultCheckCmd;
54
+ let geminiDir = findGeminiDir(getHome);
53
55
 
54
56
  if (!geminiDir) {
55
- console.error(
56
- `Antigravity not found — open Antigravity at least once to create ~/.gemini`,
57
- );
58
- exitFn(1);
59
- return;
57
+ // Detect signal #2: the `agy` CLI on PATH — installed but never launched,
58
+ // so ~/.gemini doesn't exist yet; the write path below creates it.
59
+ if (!checkCmd("agy")) {
60
+ console.error(
61
+ `Antigravity not found — open Antigravity at least once to create ~/.gemini (or put the agy CLI on PATH)`,
62
+ );
63
+ exitFn(1);
64
+ return;
65
+ }
66
+ geminiDir = join(getHome(), ".gemini");
60
67
  }
61
68
 
62
69
  // --- 1. MCP server (mcpServers.fapony in ~/.gemini/config/mcp_config.json) ---
@@ -99,6 +106,10 @@ export function cmdInstallAntigravity(
99
106
  `── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
100
107
  );
101
108
  } else {
109
+ // Escape hatch (plan §5): ~/.gemini/config may not exist yet — either
110
+ // the app never got as far as its config dir, or agy-on-PATH created
111
+ // nothing. Recursive mkdir is idempotent and non-destructive.
112
+ mkdirSync(configDir, { recursive: true });
102
113
  writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
103
114
  console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
104
115
  console.error(` restart Antigravity to load the MCP server`);
@@ -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` for claude — all
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
- : "no ~/.gemini directory (open Antigravity once)",
53
+ : agy
54
+ ? "agy CLI on PATH"
55
+ : "no ~/.gemini directory (open Antigravity once)",
53
56
  },
54
57
  {
55
58
  platform: "claude",