fapony 0.3.6 → 0.4.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 CHANGED
@@ -38,26 +38,9 @@ quietly counted as free.
38
38
 
39
39
  That is day one. Past that, fapony keeps what coding agents actually did — the frozen
40
40
  ledger of graded runs (rounds, pass/fail, cost per grade, readable via CLI, no new grades)
41
- plus the live mem log — through 3 MCP tools any agent can call. If you juggle more than
42
- one agent, this is the point: the numbers come from the same yardstick everywhere, so
43
- "which model earns its keep on which kind of task" becomes a data question instead of a
44
- vibe. On top of history it checks claims against git facts: handoff conformance and
45
- allowlisted evidence — with everything the agent claimed but couldn't prove marked as such.
46
-
47
- **What that question looks like answered, from one project's own (frozen — reads history,
48
- no new grades) ledger — the top of the `n≥5` frontier (`fapony stats --mode verdict --regime code`):**
49
-
50
- | model | tokens/pass | quality | n |
51
- |---|---|---|---|
52
- | `claude-opus-5` | 22.5M | 3.8 | 10 |
53
- | `claude-sonnet-5` | 5.6M | 3.5 | 11 |
54
- | `muse-spark-1.3-contributor-free` | 4.3M | 4.0 | 5 |
55
-
56
- Same quality band, an 8× token spread — the kind of answer a session log can't give (it has tokens,
57
- no grades) and a benchmark can't give either (it has grades, not your codebase). One caveat that's
58
- on you to hold: work isn't randomly assigned to models, so a gap this size is a strong prior, not a
59
- controlled trial — you likely route easy tasks to the cheap model already. `n≥5` is fapony's own
60
- floor before a model counts toward the frontier at all; below that it's a data point, not a pick.
41
+ plus the live mem log — through 3 MCP tools any agent can call. It checks claims against
42
+ git facts: handoff conformance and allowlisted evidence — with everything the agent claimed
43
+ but couldn't prove marked as such.
61
44
 
62
45
  **The reason to keep it running is the third layer: knowledge accumulation — and the thing it
63
46
  accumulates is pain.** An agent has no memory of pain across sessions: it writes the 37th
@@ -72,7 +55,7 @@ already decided on, that is the loop: notice the repeated cost, name the shared
72
55
  migration finish. Finding dead code and duplication is *not* part of it — knip and friends already
73
56
  do that better, and a convention with a `checker` is deliberately left to the checker.
74
57
 
75
- **The measurement layer underneath it:** Any single client already logs its own session — timing, tokens, tool calls. What none of them see is *across* runs, clients and task shapes: which model earns its keep on which kind of work **in this project**, at what token cost. The frozen ledger still answers that from history — every old verdict carries a `regime` (`code` / `fix` / `review` / `plan` / `inquiry` / `test`), and runs split by whether there was a plan at all — so "does planning beat diving in, and for which model" stays a table, not an argument. New accumulation goes to the mem log instead: decisions, bugs and notes with `files[]`, written by the agents doing the work.
58
+ **The measurement layer underneath it:** Any single client already logs its own session — timing, tokens, tool calls. What none of them see is *across* runs, clients and task shapes: what each model costs you, across clients, on one yardstick, in this project (`fapony usage-web`, above) — tokens and cost, nothing self-graded. The frozen ledger still has old verdict rows (`fapony stats`), but the tool that wrote them (`verdict_submit`) is gone from the MCP surface: no new rows, for anyone, ever again, so a count of old rows (`n`) means nothing without the grading that stopped. New accumulation goes to the mem log instead: decisions, bugs and notes with `files[]`, written by the agents doing the work.
76
59
 
77
60
  Three tiers, deliberately: **measurement ships today** and needs no per-project setup — raw facts nobody can call unfair. **Verification is the sharper edge** but stays beta until its evidence layer is hardened; fapony doesn't control your agent's flow, so it never promises "verified" as a headline. **Knowledge accumulation is the compounding one** — it's worthless on run 1 and gets more useful every run after, which is exactly why it's the layer competitors can't clone by copying a feature list.
78
61
 
@@ -381,6 +364,12 @@ at once. ZCode and Codex get the same skills linked into `~/.agents/skills`. A d
381
364
  that already exists and isn't a fapony link is reported and left alone — replace it by hand
382
365
  if you want fapony's version.
383
366
 
367
+ OpenCode is the one client whose hooks are generated files rather than `fapony hook-*`
368
+ commands, so `fapony update` also re-runs the installer in a fresh process to refresh them —
369
+ with `--plugins-only`, so a refresh touches fapony's plugin files and never your
370
+ `opencode.json`. A fapony-owned plugin is rewritten in place; a file that isn't fapony's is
371
+ reported and left alone, same as the skill links.
372
+
384
373
  `plan-with-pony` is vendor-neutral — the SKILL.md *is* the prompt, so pipe it to any agent:
385
374
 
386
375
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
@@ -35,6 +35,7 @@
35
35
  "check": "bun run lint && bun run typecheck && bun test --timeout 20000 --parallel",
36
36
  "prepublishOnly": "bash scripts/smoke-publish.sh",
37
37
  "release": "git checkout main && git pull --ff-only && npm version patch -m 'release v%s' && git push origin main --follow-tags",
38
+ "release:minor": "git checkout main && git pull --ff-only && npm version minor -m 'release v%s' && git push origin main --follow-tags",
38
39
  "overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
39
40
  },
40
41
  "devDependencies": {
@@ -2,7 +2,13 @@
2
2
  //
3
3
  // Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ } from "node:fs";
6
12
  import { homedir } from "node:os";
7
13
  import { join } from "node:path";
8
14
  import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
@@ -84,6 +90,11 @@ function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
84
90
  export interface OpencodeInstallOpts {
85
91
  /** Opt-in only: write the git-autonomy rewrite plugin. Never default. */
86
92
  gitAutonomy?: boolean;
93
+ /** Refresh only the generated plugin bodies — skip the opencode.json write
94
+ * and the skills symlink entirely. This is `fapony update`'s post-pull
95
+ * refresh: it must touch only fapony-owned plugin files, never the user's
96
+ * config (rule 6c — overwriting what exists = ask first, or refuse). */
97
+ pluginsOnly?: boolean;
87
98
  }
88
99
 
89
100
  export function cmdInstallOpencode(
@@ -94,6 +105,21 @@ export function cmdInstallOpencode(
94
105
  const exitFn = deps.exit ?? defaultExit;
95
106
  const getHome = deps.homedir ?? homedir;
96
107
 
108
+ const installPlugins = (): void => {
109
+ installReadHintPlugin(dryRun, getHome);
110
+ installCommitHintPlugin(dryRun, getHome);
111
+ installEditHintPlugin(dryRun, getHome);
112
+ installSessionStartPlugin(dryRun, getHome);
113
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
114
+ };
115
+
116
+ // Refresh path: plugin bodies only — returns before findOpencodeConfig, so
117
+ // neither opencode.json nor the skills symlink is read or written.
118
+ if (opts.pluginsOnly) {
119
+ installPlugins();
120
+ return;
121
+ }
122
+
97
123
  const configPath = findOpencodeConfig(getHome);
98
124
  let before: Record<string, unknown>;
99
125
  let isNew = false;
@@ -117,11 +143,7 @@ export function cmdInstallOpencode(
117
143
  if (configPath) console.error(` (${configPath})`);
118
144
  const skillsDir = claudeSkillsDir(getHome);
119
145
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
120
- installReadHintPlugin(dryRun, getHome);
121
- installCommitHintPlugin(dryRun, getHome);
122
- installEditHintPlugin(dryRun, getHome);
123
- installSessionStartPlugin(dryRun, getHome);
124
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
146
+ installPlugins();
125
147
  return;
126
148
  }
127
149
 
@@ -143,11 +165,7 @@ export function cmdInstallOpencode(
143
165
  console.error(` (${configPath})`);
144
166
  }
145
167
  console.log(computeDiff(before, after));
146
- installReadHintPlugin(dryRun, getHome);
147
- installCommitHintPlugin(dryRun, getHome);
148
- installEditHintPlugin(dryRun, getHome);
149
- installSessionStartPlugin(dryRun, getHome);
150
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
168
+ installPlugins();
151
169
  return;
152
170
  }
153
171
 
@@ -167,11 +185,7 @@ export function cmdInstallOpencode(
167
185
  console.error(` restart opencode to load the MCP server`);
168
186
  const skillsDir = claudeSkillsDir(getHome);
169
187
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
170
- installReadHintPlugin(dryRun, getHome);
171
- installCommitHintPlugin(dryRun, getHome);
172
- installEditHintPlugin(dryRun, getHome);
173
- installSessionStartPlugin(dryRun, getHome);
174
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
188
+ installPlugins();
175
189
  }
176
190
 
177
191
  /**
@@ -412,102 +426,102 @@ export const FaponyEditHint = async ({ directory }) => {
412
426
  `;
413
427
  }
414
428
 
415
- function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
429
+ /**
430
+ * Write one generated OpenCode plugin file, refreshing our own older copy.
431
+ *
432
+ * Ownership is the exported plugin name (`FaponyReadHint`, …) — the one token
433
+ * every generated body carries. A file without it is someone's own plugin and
434
+ * is never touched, same rule as the skill symlinks. A file *with* it but
435
+ * different content is ours and stale: the plugin body is baked at install
436
+ * time, so a `git pull` updates the imported logic (src/hook.ts) but never this
437
+ * file. Refusing to overwrite it is how an old hook stays reachable forever —
438
+ * the Thai-only bug marker survived exactly that way — so ours gets rewritten
439
+ * in place, and only a foreign file is reported and left alone.
440
+ */
441
+ function installPluginFile(
442
+ dryRun: boolean,
443
+ getHome: () => string,
444
+ fileName: string,
445
+ label: string,
446
+ ownedName: string,
447
+ desired: string,
448
+ ): void {
416
449
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
417
- const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
418
- if (existsSync(pluginPath)) {
419
- let current = "";
450
+ const pluginPath = join(pluginsDir, fileName);
451
+ const write = (): boolean => {
452
+ if (dryRun) return true;
420
453
  try {
421
- current = readFileSync(pluginPath, "utf-8");
422
- } catch {
423
- current = "";
454
+ mkdirSync(pluginsDir, { recursive: true });
455
+ writeFileSync(pluginPath, desired, "utf-8");
456
+ return true;
457
+ } catch (e) {
458
+ console.error(` ${label}: failed to write — ${(e as Error).message}`);
459
+ return false;
424
460
  }
425
- if (current.includes("readHintFor")) {
426
- console.error(` read hint: already installed — no change`);
427
- return;
461
+ };
462
+
463
+ if (!existsSync(pluginPath)) {
464
+ if (write()) {
465
+ console.error(
466
+ ` ${label}: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
467
+ );
428
468
  }
469
+ return;
470
+ }
471
+
472
+ let current = "";
473
+ try {
474
+ current = readFileSync(pluginPath, "utf-8");
475
+ } catch {
476
+ current = "";
477
+ }
478
+ if (current === desired) {
479
+ console.error(` ${label}: already installed — no change`);
480
+ return;
481
+ }
482
+ if (!current.includes(ownedName)) {
429
483
  console.error(
430
- ` read hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
484
+ ` ${label}: ${pluginPath} exists but isn't fapony's — not overwriting.`,
431
485
  );
432
486
  return;
433
487
  }
434
- if (!dryRun) {
435
- try {
436
- mkdirSync(pluginsDir, { recursive: true });
437
- writeFileSync(pluginPath, readHintPluginSource(INSTALL_ROOT), "utf-8");
438
- } catch (e) {
439
- console.error(` read hint: failed to write — ${(e as Error).message}`);
440
- return;
441
- }
488
+ if (write()) {
489
+ console.error(
490
+ ` ${label}: ${dryRun ? "would update" : "updated"} ${pluginPath}`,
491
+ );
442
492
  }
443
- console.error(
444
- ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
493
+ }
494
+
495
+ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
496
+ installPluginFile(
497
+ dryRun,
498
+ getHome,
499
+ "fapony-read-hint.ts",
500
+ "read hint",
501
+ "FaponyReadHint",
502
+ readHintPluginSource(INSTALL_ROOT),
445
503
  );
446
504
  }
447
505
 
448
506
  function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
449
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
450
- const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
451
- if (existsSync(pluginPath)) {
452
- let current = "";
453
- try {
454
- current = readFileSync(pluginPath, "utf-8");
455
- } catch {
456
- current = "";
457
- }
458
- if (current.includes("commitHintFor")) {
459
- console.error(` commit hint: already installed — no change`);
460
- return;
461
- }
462
- console.error(
463
- ` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
464
- );
465
- return;
466
- }
467
- if (!dryRun) {
468
- try {
469
- mkdirSync(pluginsDir, { recursive: true });
470
- writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
471
- } catch (e) {
472
- console.error(` commit hint: failed to write — ${(e as Error).message}`);
473
- return;
474
- }
475
- }
476
- console.error(
477
- ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
507
+ installPluginFile(
508
+ dryRun,
509
+ getHome,
510
+ "fapony-commit-hint.ts",
511
+ "commit hint",
512
+ "FaponyCommitHint",
513
+ commitHintPluginSource(INSTALL_ROOT),
478
514
  );
479
515
  }
480
516
 
481
517
  function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
482
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
483
- const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
484
- if (existsSync(pluginPath)) {
485
- let current = "";
486
- try {
487
- current = readFileSync(pluginPath, "utf-8");
488
- } catch {
489
- current = "";
490
- }
491
- if (current.includes("editHintFor")) {
492
- console.error(` edit hint: already installed — no change`);
493
- return;
494
- }
495
- console.error(
496
- ` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
497
- );
498
- return;
499
- }
500
- if (!dryRun) {
501
- try {
502
- mkdirSync(pluginsDir, { recursive: true });
503
- writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
504
- } catch (e) {
505
- console.error(` edit hint: failed to write — ${(e as Error).message}`);
506
- return;
507
- }
508
- }
509
- console.error(
510
- ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
518
+ installPluginFile(
519
+ dryRun,
520
+ getHome,
521
+ "fapony-edit-hint.ts",
522
+ "edit hint",
523
+ "FaponyEditHint",
524
+ editHintPluginSource(INSTALL_ROOT),
511
525
  );
512
526
  }
513
527
 
@@ -571,50 +585,13 @@ function installSessionStartPlugin(
571
585
  dryRun: boolean,
572
586
  getHome: () => string,
573
587
  ): void {
574
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
575
- const pluginPath = join(pluginsDir, "fapony-session-start.ts");
576
- if (existsSync(pluginPath)) {
577
- let current = "";
578
- try {
579
- current = readFileSync(pluginPath, "utf-8");
580
- } catch {
581
- current = "";
582
- }
583
- if (current.includes("FaponySessionStart")) {
584
- // Ours — and generated, so never overwritten. A file baked by an older
585
- // fapony differs from what we generate now: say so instead of "no change",
586
- // which is how the pre-hook plugin (mub2ezhi) would have been kept.
587
- if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
588
- console.error(
589
- ` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
590
- );
591
- return;
592
- }
593
- console.error(` session start: already installed — no change`);
594
- return;
595
- }
596
- console.error(
597
- ` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
598
- );
599
- return;
600
- }
601
- if (!dryRun) {
602
- try {
603
- mkdirSync(pluginsDir, { recursive: true });
604
- writeFileSync(
605
- pluginPath,
606
- sessionStartPluginSource(INSTALL_ROOT),
607
- "utf-8",
608
- );
609
- } catch (e) {
610
- console.error(
611
- ` session start: failed to write — ${(e as Error).message}`,
612
- );
613
- return;
614
- }
615
- }
616
- console.error(
617
- ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
588
+ installPluginFile(
589
+ dryRun,
590
+ getHome,
591
+ "fapony-session-start.ts",
592
+ "session start",
593
+ "FaponySessionStart",
594
+ sessionStartPluginSource(INSTALL_ROOT),
618
595
  );
619
596
  }
620
597
 
@@ -680,45 +657,27 @@ function installGitAutonomyPlugin(
680
657
  dryRun: boolean,
681
658
  getHome: () => string,
682
659
  ): void {
660
+ installPluginFile(
661
+ dryRun,
662
+ getHome,
663
+ "fapony-git-autonomy.ts",
664
+ "git-autonomy",
665
+ "FaponyGitAutonomy",
666
+ gitAutonomyPluginSource(INSTALL_ROOT),
667
+ );
668
+ }
669
+
670
+ /** The fapony-generated plugin files present in an OpenCode install. Empty =
671
+ * OpenCode has none, so `fapony update` must not install into a client the user
672
+ * never opted into. Callers also use it to tell whether the opt-in git-autonomy
673
+ * plugin is installed, so an update can refresh it without ever creating it. */
674
+ export function opencodePluginFiles(getHome: () => string = homedir): string[] {
683
675
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
684
- const pluginPath = join(pluginsDir, "fapony-git-autonomy.ts");
685
- if (existsSync(pluginPath)) {
686
- let current = "";
687
- try {
688
- current = readFileSync(pluginPath, "utf-8");
689
- } catch {
690
- current = "";
691
- }
692
- if (current.includes("FaponyGitAutonomy")) {
693
- // Ours — and generated, so never overwritten. Same stale-check shape
694
- // as the session-start plugin: an older generation must say so instead
695
- // of "no change", or a fixed rewrite never reaches the client.
696
- if (current !== gitAutonomyPluginSource(INSTALL_ROOT)) {
697
- console.error(
698
- ` git-autonomy: stale fapony plugin — delete ${pluginPath} and re-run install --git-autonomy`,
699
- );
700
- return;
701
- }
702
- console.error(` git-autonomy: already installed — no change`);
703
- return;
704
- }
705
- console.error(
706
- ` git-autonomy: ${pluginPath} exists but isn't fapony's — not overwriting.`,
676
+ try {
677
+ return readdirSync(pluginsDir).filter(
678
+ (f) => f.startsWith("fapony-") && f.endsWith(".ts"),
707
679
  );
708
- return;
680
+ } catch {
681
+ return [];
709
682
  }
710
- if (!dryRun) {
711
- try {
712
- mkdirSync(pluginsDir, { recursive: true });
713
- writeFileSync(pluginPath, gitAutonomyPluginSource(INSTALL_ROOT), "utf-8");
714
- } catch (e) {
715
- console.error(
716
- ` git-autonomy: failed to write — ${(e as Error).message}`,
717
- );
718
- return;
719
- }
720
- }
721
- console.error(
722
- ` git-autonomy: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
723
- );
724
683
  }
package/src/install.ts CHANGED
@@ -39,7 +39,9 @@ export {
39
39
  editHintPluginSource,
40
40
  gitAutonomyPluginSource,
41
41
  type OpencodeInstallOpts,
42
+ opencodePluginFiles,
42
43
  readHintPluginSource,
44
+ sessionStartPluginSource,
43
45
  } from "./install/opencode.js";
44
46
  export {
45
47
  agentsSkillsDir,
@@ -64,6 +66,8 @@ export async function cmdInstall(
64
66
  // not a utility — never installed unless this flag rides along, including
65
67
  // via --all. Opencode-only; other platforms ignore it.
66
68
  const gitAutonomy = args.includes("--git-autonomy");
69
+ // Refresh-only seam for `fapony update`: plugin bodies, never opencode.json.
70
+ const pluginsOnly = args.includes("--plugins-only");
67
71
 
68
72
  // --- explicit platform: original behavior, unchanged ---
69
73
  if (platform === "antigravity") {
@@ -87,12 +91,12 @@ export async function cmdInstall(
87
91
  return;
88
92
  }
89
93
  if (platform === "opencode") {
90
- cmdInstallOpencode(dryRun, deps, { gitAutonomy });
94
+ cmdInstallOpencode(dryRun, deps, { gitAutonomy, pluginsOnly });
91
95
  return;
92
96
  }
93
97
  if (platform !== undefined) {
94
98
  console.error(
95
- `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy]`,
99
+ `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
96
100
  );
97
101
  console.error(
98
102
  ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
@@ -131,7 +135,10 @@ export async function cmdInstall(
131
135
  if (installAll) {
132
136
  console.error();
133
137
  for (const client of found) {
134
- installPlatform(client.platform, dryRun, deps, { gitAutonomy });
138
+ installPlatform(client.platform, dryRun, deps, {
139
+ gitAutonomy,
140
+ pluginsOnly,
141
+ });
135
142
  }
136
143
  return;
137
144
  }
@@ -160,7 +167,10 @@ export async function cmdInstall(
160
167
  for (const client of found) {
161
168
  const answer = await askFn(`install into ${client.platform}?`, "Y");
162
169
  if (isAffirmative(answer)) {
163
- installPlatform(client.platform, dryRun, deps, { gitAutonomy });
170
+ installPlatform(client.platform, dryRun, deps, {
171
+ gitAutonomy,
172
+ pluginsOnly,
173
+ });
164
174
  }
165
175
  }
166
176
  } finally {
@@ -172,7 +182,7 @@ function installPlatform(
172
182
  platform: string,
173
183
  dryRun: boolean,
174
184
  deps: InstallDeps,
175
- opts: { gitAutonomy?: boolean } = {},
185
+ opts: { gitAutonomy?: boolean; pluginsOnly?: boolean } = {},
176
186
  ): void {
177
187
  switch (platform) {
178
188
  case "antigravity":
package/src/update.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  // ROOT must be the repo root: import.meta.dir is src/, one level below it.
3
3
  // Shows old → new version, recent commits, and warns if uncommitted changes.
4
4
 
5
- import { execSync } from "node:child_process";
5
+ import { execSync, spawnSync } from "node:child_process";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { createInterface } from "node:readline";
10
+ import { opencodePluginFiles } from "./install/opencode.js";
9
11
  import { isAffirmative } from "./util.js";
10
12
 
11
13
  /** Repo root (parent of src/) — where package.json and bun.lock live.
@@ -27,12 +29,59 @@ function defaultInstall(): void {
27
29
  execSync("bun install", { cwd: ROOT, stdio: "pipe", timeout: 300_000 });
28
30
  }
29
31
 
32
+ /** argv for the spawned plugin refresh — exported so tests can pin the flags:
33
+ * a wrong flag fails silently (the child would just do a full install and
34
+ * rewrite opencode.json, the exact bug `--plugins-only` exists to prevent). */
35
+ export function refreshArgv(files: string[]): string[] {
36
+ const argv = [
37
+ join(ROOT, "fapony.ts"),
38
+ "install",
39
+ "--platform",
40
+ "opencode",
41
+ // Plugins only: a refresh touches fapony-owned plugin files, never the
42
+ // user's opencode.json (rule 6c).
43
+ "--plugins-only",
44
+ ];
45
+ // Opt-in plugin: refresh it when the user installed it, never create it.
46
+ if (files.includes("fapony-git-autonomy.ts")) argv.push("--git-autonomy");
47
+ return argv;
48
+ }
49
+
50
+ /**
51
+ * Refresh OpenCode's generated plugin bodies after the pull.
52
+ *
53
+ * OpenCode is the only client whose hooks are baked files — every other client
54
+ * writes a `fapony hook-*` command resolved at run time, so a pull alone keeps
55
+ * them current. Spawning a *fresh* process is the whole point: this one already
56
+ * loaded the pre-pull templates, so calling the installer in-process would
57
+ * rewrite the old body — the exact bug this exists to fix. `process.execPath`
58
+ * is the bun running fapony, so no PATH dependency. Runs `--plugins-only`, so
59
+ * the refresh never reads or writes opencode.json or the skills symlink.
60
+ * Best-effort: a refresh must never fail an update.
61
+ */
62
+ function defaultRefreshPlugins(): void {
63
+ const getHome = (): string => homedir();
64
+ const files = opencodePluginFiles(getHome);
65
+ if (files.length === 0) return;
66
+ console.log("\n Refreshing OpenCode plugins...");
67
+ const r = spawnSync(process.execPath, refreshArgv(files), {
68
+ stdio: "pipe",
69
+ timeout: 30_000,
70
+ });
71
+ if (r.error || r.status !== 0) {
72
+ console.log(
73
+ " ⚠ plugin refresh failed — run manually: fapony install --platform opencode --plugins-only",
74
+ );
75
+ }
76
+ }
77
+
30
78
  /** Minimal seam for cmdUpdate — git runner (map args→result, throws on failure),
31
- * prompt, exit, and bun-install. Every field is used by both the default
32
- * (production) path and the test path. */
79
+ * prompt, exit, bun-install, and the post-pull OpenCode plugin refresh. Every
80
+ * field is used by both the default (production) path and the test path. */
33
81
  export interface UpdateDeps {
34
82
  git?: (args: string) => string;
35
83
  install?: () => void;
84
+ refresh?: () => void;
36
85
  prompt?: (question: string, defaultVal?: string) => Promise<string>;
37
86
  exit?: (code: number) => never;
38
87
  }
@@ -88,6 +137,7 @@ function defaultPrompt(question: string, defaultVal?: string): Promise<string> {
88
137
  export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
89
138
  const git = deps.git ?? defaultGit;
90
139
  const installFn = deps.install ?? defaultInstall;
140
+ const refreshFn = deps.refresh ?? defaultRefreshPlugins;
91
141
  const promptFn = deps.prompt ?? defaultPrompt;
92
142
  const exitFn = deps.exit ?? ((code: number): never => process.exit(code));
93
143
  const gitQuiet = (args: string): string | null => {
@@ -173,6 +223,10 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
173
223
  // --- show what changed ---
174
224
  if (isUpToDate(oldSha, newSha)) {
175
225
  console.log(`\n ✓ Already up to date (${oldVersion} @ ${oldSha}).`);
226
+ // The repo being current says nothing about the generated plugin bodies —
227
+ // a user who pulled by hand, or installed before the template changed, is
228
+ // exactly who needs this. Refresh is a no-op when nothing is stale.
229
+ refreshFn();
176
230
  return;
177
231
  }
178
232
 
@@ -201,6 +255,9 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
201
255
  }
202
256
  }
203
257
 
258
+ // --- refresh generated plugin bodies (after deps — the fresh process needs them) ---
259
+ refreshFn();
260
+
204
261
  console.log(`
205
262
  ┌──────────────────────────────────────────┐
206
263
  │ Update complete! │