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.
@@ -1,4 +1,4 @@
1
- // src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
1
+ // src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]...`
2
2
  //
3
3
  // Writes PLAN + SPEC straight into planDir/specDir. What it pre-fills is the
4
4
  // structure (frontmatter, the 8 sections, prior art, ledger context) — the
@@ -43,8 +43,9 @@ import {
43
43
  planDir,
44
44
  specDir,
45
45
  } from "../core/config.js";
46
+ import { MEM_TEXT_MAX } from "../core/mem-log.js";
46
47
  import { extractExports } from "../map.js";
47
- import { readRecentMemDecisions } from "../memory.js";
48
+ import { readMemLog, readRecentMemDecisions } from "../memory.js";
48
49
  import { capLines, execGit, SIG_MAX } from "./primitives.js";
49
50
 
50
51
  // One chunk = one module's signatures — past ~40 lines a module is its own
@@ -301,6 +302,7 @@ One step = one chunk = one session: finish it, close it, **stop** — starting t
301
302
  1. _(agent fills in — each step must be verifiable)_
302
303
 
303
304
  **Closing a step:** tick TL;DR with sha · \`git commit\` files only · \`fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}\` · next opens with \`kickoff ${planRel}\` (or \`kickoff PLAN-${name}.md\` — kickoff resolves by filename too).
305
+ - [ ] handoff: the mem note is the handoff — this box only opts the plan into the Stop-hook check
304
306
 
305
307
  ## 7. Examples
306
308
  ${
@@ -507,6 +509,7 @@ function specTemplate(
507
509
  name: string,
508
510
  chunks: Chunk[],
509
511
  scopeEcho: string | null,
512
+ traps: string[],
510
513
  ): string {
511
514
  const index = chunks.map((c) => `- [${c.title}](#${c.slug})`).join("\n");
512
515
  // The index is one string with a newline per chunk; budgeting it as one line
@@ -534,12 +537,18 @@ function specTemplate(
534
537
  const tail = [
535
538
  "## (agent fills in — wireframes / edge cases / API shapes the plan references)",
536
539
  ];
537
- const bodyLines = chunks.flatMap((c) => [
538
- `## <a id="${c.slug}"></a>${c.title}`,
539
- "",
540
- ...c.body.split("\n"),
541
- "",
542
- ]);
540
+ const bodyLines = [
541
+ // Known traps sit after the index, before the first chunk body — budgeted
542
+ // with the bodies so the whole-SPEC cap still cuts from the tail and the
543
+ // count line above survives (a silent section would teach skipping it).
544
+ ...(traps.length > 0 ? [...traps, ""] : []),
545
+ ...chunks.flatMap((c) => [
546
+ `## <a id="${c.slug}"></a>${c.title}`,
547
+ "",
548
+ ...c.body.split("\n"),
549
+ "",
550
+ ]),
551
+ ];
543
552
  // Whole-file cap runs last and the agent section survives it, same way
544
553
  // review-seed reserves its disclaimer: reserve the tail, cut the middle,
545
554
  // say how much was dropped.
@@ -552,10 +561,107 @@ function specTemplate(
552
561
  return `${[...head, ...cappedBody, ...tail].join("\n")}\n`;
553
562
  }
554
563
 
564
+ // --- Known traps (PLAN-active-pain chunk 2): mem rows on this scope ---
565
+ //
566
+ // The one place a seed is allowed to be opinionated: past pain about exactly
567
+ // these files. bug rows first, then decision (note carries no "this hurt"
568
+ // signal), newest first within a kind. Match mirrors mem_find: files[] first;
569
+ // the text/spec fallback runs ONLY for rows with no files[] at all — a row
570
+ // that named files already spoke, its text may quote any path.
571
+ //
572
+ // Measured base rate on this repo before building (rule 2, 2026-09-23):
573
+ // 248 rows total; scope src/seed → 5 matched (3 bug/2 decision, 0 lacked) ·
574
+ // src/mem → 12 (4/8, 0) · src/adapters/hooks → 6 (2/4, 0) · whole repo → 61
575
+ // (20/41, 0). The "M lacked files[]" fallback layer fired 0/4 scopes here —
576
+ // kept because a pre-files[] repo (vela's 2,672 rows) depends on it.
577
+ const MAX_TRAP_ROWS = 5;
578
+
579
+ interface TrapHit {
580
+ row: {
581
+ ts: string;
582
+ kind: string;
583
+ text: string;
584
+ files?: string[];
585
+ spec?: string;
586
+ };
587
+ /** First in-scope file[] entry that matched — null for the text fallback. */
588
+ file: string | null;
589
+ viaText: boolean;
590
+ }
591
+
592
+ export function renderKnownTraps(
593
+ worktree: string,
594
+ cwd: string,
595
+ roots: string[],
596
+ scoped: boolean,
597
+ ): { lines: string[]; matched: number; lacked: number } {
598
+ const empty = { lines: [], matched: 0, lacked: 0 };
599
+ try {
600
+ const { rows, filesFound } = readMemLog(worktree);
601
+ if (filesFound === 0 || rows.length === 0) return empty;
602
+
603
+ const scopeFiles = new Set<string>();
604
+ for (const r of roots)
605
+ for (const f of scopeSourceFiles(r)) scopeFiles.add(relative(cwd, f));
606
+ if (scopeFiles.size === 0) return empty;
607
+ const scopeKeys = roots
608
+ .map((r) => relative(cwd, r))
609
+ .filter((k) => k !== "" && k !== ".");
610
+
611
+ const hits: TrapHit[] = [];
612
+ for (const row of rows) {
613
+ if (row.kind !== "bug" && row.kind !== "decision") continue;
614
+ const files = row.files ?? [];
615
+ const inScope = files.find((f) => scopeFiles.has(f));
616
+ if (inScope) {
617
+ hits.push({ row, file: inScope, viaText: false });
618
+ continue;
619
+ }
620
+ // Fallback: only a row with NO files[] — see comment above.
621
+ if (files.length === 0 && scoped) {
622
+ const hay = `${row.text}\n${row.spec ?? ""}`;
623
+ if (scopeKeys.some((k) => hay.includes(k))) {
624
+ hits.push({ row, file: null, viaText: true });
625
+ }
626
+ }
627
+ }
628
+ if (hits.length === 0) return empty;
629
+
630
+ // Stable sort: bug before decision, recency preserved inside each kind.
631
+ hits.sort((a, b) =>
632
+ a.row.kind === b.row.kind ? 0 : a.row.kind === "bug" ? -1 : 1,
633
+ );
634
+ const lacked = hits.filter((h) => h.viaText).length;
635
+ const lines = [
636
+ "## Known traps (fapony mem)",
637
+ "",
638
+ `- ${hits.length} relevant row(s) on this scope (${lacked} lacked files[]${lacked > 0 ? " — matched via text" : ""})`,
639
+ ];
640
+ for (const h of hits.slice(0, MAX_TRAP_ROWS)) {
641
+ const text =
642
+ h.row.text.length > MEM_TEXT_MAX
643
+ ? `${h.row.text.slice(0, MEM_TEXT_MAX - 1)}…`
644
+ : h.row.text;
645
+ lines.push(
646
+ `- ${h.row.ts.slice(0, 10)} ${h.row.kind} — ${text}${h.file ? ` (${h.file})` : ""}`,
647
+ );
648
+ }
649
+ if (hits.length > MAX_TRAP_ROWS) {
650
+ lines.push(
651
+ `- … +${hits.length - MAX_TRAP_ROWS} more at cap ${MAX_TRAP_ROWS} (bug first, then decision)`,
652
+ );
653
+ }
654
+ return { lines, matched: hits.length, lacked };
655
+ } catch {
656
+ return empty; // no mem log / unreadable = a seed with no traps, never an error
657
+ }
658
+ }
659
+
555
660
  // --- CLI entry ---
556
661
 
557
662
  export function cmdPlanSeed(args: string[]): void {
558
- const usage = "usage: fapony plan-seed <name> [--spec] [--scope <path>]...";
663
+ const usage =
664
+ "usage: fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]...";
559
665
  // Positional parse, not args.find(!startsWith("--")) — a --scope VALUE is
560
666
  // a non-flag argument and must never be mistaken for the plan name.
561
667
  let name: string | undefined;
@@ -571,7 +677,20 @@ export function cmdPlanSeed(args: string[]): void {
571
677
  console.error(`plan-seed: --scope needs a path\n${usage}`);
572
678
  process.exit(1);
573
679
  }
574
- scopeArgs.push(v);
680
+ // Agents expect `--scope a,b,c` — same comma shape as `--files` — so a
681
+ // comma list is split here instead of failing on a path that never is.
682
+ // A real repo dir can't contain a literal ",x" tail; any comma splits.
683
+ // Trim each segment too: `a, b` is the same habit as `a,b`, and the
684
+ // space must not become part of the path (PLAN-comma-x).
685
+ const syms = v
686
+ .split(",")
687
+ .map((s) => s.trim())
688
+ .filter(Boolean);
689
+ if (syms.length === 0) {
690
+ console.error(`plan-seed: --scope needs a path\n${usage}`);
691
+ process.exit(1);
692
+ }
693
+ scopeArgs.push(...syms);
575
694
  i++;
576
695
  } else if (a.startsWith("--")) {
577
696
  console.error(`plan-seed: unknown flag "${a}"\n${usage}`);
@@ -643,6 +762,7 @@ export function cmdPlanSeed(args: string[]): void {
643
762
  );
644
763
 
645
764
  let specLink: string | null = null;
765
+ let traps = { lines: [] as string[], matched: 0, lacked: 0 };
646
766
  if (withSpec) {
647
767
  const specDirAbs = join(cwd, specDir());
648
768
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
@@ -652,6 +772,7 @@ export function cmdPlanSeed(args: string[]): void {
652
772
  );
653
773
  process.exit(1);
654
774
  }
775
+ traps = renderKnownTraps(worktree, cwd, roots, scoped);
655
776
  const chunks = buildChunks(roots, cwd);
656
777
  mkdirSync(specDirAbs, { recursive: true });
657
778
  writeFileSync(
@@ -662,6 +783,7 @@ export function cmdPlanSeed(args: string[]): void {
662
783
  requested.length > 0
663
784
  ? roots.map((r) => relative(cwd, r) || ".").join(", ")
664
785
  : null,
786
+ traps.lines,
665
787
  ),
666
788
  );
667
789
  specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
@@ -691,6 +813,13 @@ export function cmdPlanSeed(args: string[]): void {
691
813
  }
692
814
  writeFileSync(planPath, planBody);
693
815
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
816
+ // Measurement surface (plan §3): a silent section is indistinguishable from
817
+ // "no mem log" — say what was injected so a week of seeds is countable.
818
+ if (traps.matched > 0) {
819
+ console.log(
820
+ `Known traps: ${traps.matched} row(s) injected (${traps.lacked} lacked files[])`,
821
+ );
822
+ }
694
823
  console.log("Existing plans:");
695
824
  for (const l of listExistingPlans(cwd, config, `PLAN-${name}.md`))
696
825
  console.log(l);
@@ -21,7 +21,7 @@
21
21
 
22
22
  import type { Stats } from "node:fs";
23
23
  import { existsSync, readFileSync, statSync } from "node:fs";
24
- import { isAbsolute, join, resolve } from "node:path";
24
+ import { basename, isAbsolute, join, resolve } from "node:path";
25
25
  import {
26
26
  buildGraph,
27
27
  collectSourceFiles,
@@ -61,7 +61,7 @@ const LOOKUP_OUTPUT_CAP = 120;
61
61
  const DISCLAIMER =
62
62
  "static graph only — seed is where to enter, not what is verified";
63
63
  const USAGE =
64
- "usage: fapony review-seed [--staged | --commit <sha> | --range <a...b> | --files f1,f2,dir | --plan <PLAN.md>] [--body sym[,sym]] [--callers sym]";
64
+ "usage: fapony review-seed [--staged | --commit <sha> | --range <a...b> | --files f1,f2,dir | --plan <PLAN.md>] [--body sym[,sym]] [--callers sym[,sym]]";
65
65
  // --body / --callers are the executor's lookup, not the reviewer's seed: when
66
66
  // either is present the output is only those sections (plus worktree line and
67
67
  // disclaimer) — the standard sections would be a wall around the one answer.
@@ -82,13 +82,13 @@ type Scope =
82
82
  interface LookupFlags {
83
83
  /** --body sym[,sym] — declaration slices from the named file(s). */
84
84
  body: string[];
85
- /** --callers sym — symbol→symbol grep over importer files. */
86
- callers: string | null;
85
+ /** --callers sym[,sym] — symbol→symbol grep over importer files. */
86
+ callers: string[];
87
87
  }
88
88
 
89
89
  function parseLookup(args: string[]): LookupFlags {
90
90
  const body: string[] = [];
91
- let callers: string | null = null;
91
+ const callers: string[] = [];
92
92
  for (let i = 0; i < args.length; i++) {
93
93
  const a = args[i];
94
94
  if (a === "--body" || a === "--callers") {
@@ -97,29 +97,21 @@ function parseLookup(args: string[]): LookupFlags {
97
97
  throw new SeedError(`review-seed: ${a} needs a value\n${USAGE}`);
98
98
  }
99
99
  i++;
100
- if (a === "--body") {
101
- for (const s of v
102
- .split(",")
103
- .map((s) => s.trim())
104
- .filter(Boolean)) {
105
- if (!/^[A-Za-z_$][\w$]*$/.test(s)) {
106
- throw new SeedError(`review-seed: invalid symbol: ${s}`);
107
- }
108
- body.push(s);
109
- }
110
- if (body.length === 0) {
111
- throw new SeedError(`review-seed: --body needs a symbol\n${USAGE}`);
112
- }
113
- } else {
114
- if (!/^[A-Za-z_$][\w$]*$/.test(v)) {
115
- throw new SeedError(`review-seed: invalid symbol: ${v}`);
116
- }
117
- if (callers) {
118
- throw new SeedError(
119
- `review-seed: --callers takes one symbol\n${USAGE}`,
120
- );
100
+ // Both flags take the same comma shape --files takes: split, trim,
101
+ // drop empties, validate each symbol (PLAN-comma-x).
102
+ const syms = v
103
+ .split(",")
104
+ .map((s) => s.trim())
105
+ .filter(Boolean);
106
+ if (syms.length === 0) {
107
+ throw new SeedError(`review-seed: ${a} needs a symbol\n${USAGE}`);
108
+ }
109
+ for (const s of syms) {
110
+ if (!/^[A-Za-z_$][\w$]*$/.test(s)) {
111
+ throw new SeedError(`review-seed: invalid symbol: ${s}`);
121
112
  }
122
- callers = v;
113
+ if (a === "--body") body.push(s);
114
+ else callers.push(s);
123
115
  }
124
116
  }
125
117
  }
@@ -176,12 +168,32 @@ function parseScope(args: string[]): Scope {
176
168
  }
177
169
  }
178
170
  if (flags.length === 0) return { kind: "default" };
179
- if (flags.length > 1) {
171
+ // Repeats of the SAME kind: --files occurrences merge into one scope list
172
+ // (an agent splitting a lookup across two --files tokens is the same shape
173
+ // as the comma list it already accepts); an identical scalar repeat is
174
+ // idempotent; a scalar repeated with a DIFFERENT value is ambiguous and
175
+ // errors — never last-wins (PLAN-comma-x chunk 2). The mixed-scope guard
176
+ // below counts distinct kinds, not occurrences, so `--files a --files b`
177
+ // is one scope, not "files, files".
178
+ const merged = new Map<string, Scope>();
179
+ for (const f of flags) {
180
+ const prev = merged.get(f.kind);
181
+ if (prev === undefined) {
182
+ merged.set(f.kind, f);
183
+ } else if (f.kind === "files" && prev.kind === "files") {
184
+ prev.list = [...new Set([...prev.list, ...f.list])];
185
+ } else if (JSON.stringify(prev) !== JSON.stringify(f)) {
186
+ throw new SeedError(
187
+ `review-seed: --${f.kind} given twice with different values\n${USAGE}`,
188
+ );
189
+ }
190
+ }
191
+ if (merged.size > 1) {
180
192
  throw new SeedError(
181
- `review-seed: one scope flag at a time (got ${flags.map((f) => f.kind).join(", ")})\n${USAGE}`,
193
+ `review-seed: one scope flag at a time (got ${[...merged.keys()].join(", ")})\n${USAGE}`,
182
194
  );
183
195
  }
184
- return flags[0];
196
+ return [...merged.values()][0];
185
197
  }
186
198
 
187
199
  // --- Scope resolution: one flag = one declared git call ---
@@ -376,6 +388,57 @@ function resolvePlanPath(arg: string, cwd: string, worktree: string): string {
376
388
  throw new SeedError(`review-seed: plan file not found: ${arg}`);
377
389
  }
378
390
 
391
+ // Fallback commit resolution for --plan without files[] frontmatter.
392
+ // Tries two sources in order:
393
+ // 1. `> **Commits:** sha1 sha2 …` line in the plan header
394
+ // 2. `git log --grep <PLAN-filename> --format=%h`
395
+ // Returns the first source that yields commits, with metadata for the label.
396
+ function isCommitObject(sha: string, cwd: string): boolean {
397
+ return execGit(`git cat-file -e ${sha}^{commit}`, cwd).ok;
398
+ }
399
+
400
+ function planFallbackCommits(
401
+ planText: string,
402
+ planBase: string,
403
+ cwd: string,
404
+ ): { sha: string; short: string; source: "header" | "git log grep" }[] {
405
+ // Source 1: > **Commits:** line in the plan header (first 2048 bytes)
406
+ const head = planText.slice(0, 2048);
407
+ const commitsLine = />\s*\*?\*?Commits:?\*?\*?\s+(.+)/i.exec(head);
408
+ if (commitsLine) {
409
+ const shas = commitsLine[1]
410
+ .match(/\b[0-9a-f]{7,12}\b/g)
411
+ ?.filter((s) => isCommitObject(s, cwd))
412
+ .map((s) => ({
413
+ sha: s,
414
+ short: s,
415
+ source: "header" as const,
416
+ }));
417
+ if (shas && shas.length > 0) return shas;
418
+ }
419
+ // Source 2: git log --grep for the plan name. Chunk commits cite the plan
420
+ // as "(PLAN-x chunk N)" — never with the .md suffix — so grep the stem:
421
+ // it still matches messages that do carry the suffix (substring).
422
+ const stem = planBase.replace(/\.md$/, "");
423
+ const logResult = execGit(
424
+ `git log --grep=${stem} --format=%h --max-count=10`,
425
+ cwd,
426
+ );
427
+ if (logResult.ok && logResult.output.trim()) {
428
+ const shas = logResult.output
429
+ .trim()
430
+ .split("\n")
431
+ .filter(Boolean)
432
+ .map((s) => ({
433
+ sha: s,
434
+ short: s,
435
+ source: "git log grep" as const,
436
+ }));
437
+ if (shas.length > 0) return shas;
438
+ }
439
+ return [];
440
+ }
441
+
379
442
  function resolveScope(
380
443
  scope: Scope,
381
444
  cwd: string,
@@ -394,15 +457,33 @@ function resolveScope(
394
457
  }
395
458
  if (scope.kind === "plan") {
396
459
  const planPath = resolvePlanPath(scope.path, cwd, worktree);
397
- const planFiles = planFrontFiles(readFileSync(planPath, "utf-8"));
460
+ const planText = readFileSync(planPath, "utf-8");
461
+ const planFiles = planFrontFiles(planText);
398
462
  if (planFiles === null) {
399
- // No files[] to scope from — fall back to the default diff, say so.
463
+ // No files[] — try fallback commit sources before default diff.
464
+ const planBase = basename(planPath);
465
+ const fallbackCommits = planFallbackCommits(planText, planBase, cwd);
466
+ if (fallbackCommits.length > 0) {
467
+ // Use the fallback commits as the scope: get the files touched by those commits.
468
+ const shaList = fallbackCommits.map((c) => c.sha).join(" ");
469
+ const entries = parseNumstat(
470
+ execGit(`git diff-tree --no-commit-id --numstat -r ${shaList}`, cwd)
471
+ .output,
472
+ );
473
+ const source = fallbackCommits[0].source;
474
+ return {
475
+ label: `--plan ${scope.path} (commits via ${source}: ${fallbackCommits.map((c) => c.short).join(", ")})`,
476
+ entries,
477
+ };
478
+ }
479
+ // No commits found either — fall back to the default diff, say so.
400
480
  const entries = [
401
481
  ...parseNumstat(execGit("git diff HEAD --numstat -M", cwd).output),
402
482
  ...untrackedFiles(execGit("git status --porcelain -uall", cwd).output),
403
483
  ];
404
484
  return {
405
- label: "--plan (no files: frontmatter) — diff HEAD + untracked",
485
+ label:
486
+ "--plan (no files: frontmatter, no commits found) — diff HEAD + untracked",
406
487
  entries,
407
488
  };
408
489
  }
@@ -597,7 +678,7 @@ function renderLookup(
597
678
  lines.push(`worktree: ${worktree} (${lookupLabel(flags)})`);
598
679
 
599
680
  let resolved: ResolvedScope | null = null;
600
- if (flags.callers) {
681
+ if (flags.callers.length > 0) {
601
682
  resolved = resolveScope(scope, cwd, worktree);
602
683
  }
603
684
 
@@ -651,30 +732,34 @@ function renderLookup(
651
732
  }
652
733
  }
653
734
 
654
- if (flags.callers) {
735
+ if (flags.callers.length > 0) {
655
736
  const graph = buildGraph(worktree);
656
737
  const targets = (resolved?.entries ?? [])
657
738
  .map((e) => e.path)
658
739
  .filter(hasGraph);
659
- const found = findCallers(flags.callers, targets, graph, worktree);
660
- if (targets.length === 0) {
661
- lines.push(`callers of ${flags.callers}: no source files in scope`);
662
- } else if (found.rows.length === 0) {
663
- lines.push(
664
- `callers of ${flags.callers}: none found in static importers (dynamic or non-importing use is out of reach)`,
665
- );
666
- } else {
667
- lines.push(
668
- `callers of ${flags.callers} (textual hits, may be comments/strings):`,
669
- );
670
- for (const f of found.rows) {
671
- const more = f.more > 0 ? ` (+${f.more} more hits)` : "";
672
- lines.push(` ${f.file}:${f.hits.join(",")}${more}`);
673
- }
674
- if (found.filesCapped) {
740
+ // One section per symbol — a merged any-of scan would lose which
741
+ // symbol hit, and a single symbol's output stays byte-identical.
742
+ for (const sym of flags.callers) {
743
+ const found = findCallers(sym, targets, graph, worktree);
744
+ if (targets.length === 0) {
745
+ lines.push(`callers of ${sym}: no source files in scope`);
746
+ } else if (found.rows.length === 0) {
675
747
  lines.push(
676
- ` ⚠ more importer files matched — capped at ${MAX_CALLER_FILES}`,
748
+ `callers of ${sym}: none found in static importers (dynamic or non-importing use is out of reach)`,
677
749
  );
750
+ } else {
751
+ lines.push(
752
+ `callers of ${sym} (textual hits, may be comments/strings):`,
753
+ );
754
+ for (const f of found.rows) {
755
+ const more = f.more > 0 ? ` (+${f.more} more hits)` : "";
756
+ lines.push(` ${f.file}:${f.hits.join(",")}${more}`);
757
+ }
758
+ if (found.filesCapped) {
759
+ lines.push(
760
+ ` ⚠ more importer files matched — capped at ${MAX_CALLER_FILES}`,
761
+ );
762
+ }
678
763
  }
679
764
  }
680
765
  }
@@ -686,7 +771,9 @@ function renderLookup(
686
771
  function lookupLabel(flags: LookupFlags): string {
687
772
  const parts: string[] = [];
688
773
  if (flags.body.length > 0) parts.push(`--body ${flags.body.join(",")}`);
689
- if (flags.callers) parts.push(`--callers ${flags.callers}`);
774
+ if (flags.callers.length > 0) {
775
+ parts.push(`--callers ${flags.callers.join(",")}`);
776
+ }
690
777
  return parts.join(" ");
691
778
  }
692
779
 
@@ -709,7 +796,7 @@ export function renderSeed(args: string[], cwd: string): string {
709
796
  // feeds --callers its targets), but the standard sections are suppressed —
710
797
  // the caller asked for one answer, not the review seed around it.
711
798
  const lookup = parseLookup(args);
712
- if (lookup.body.length > 0 || lookup.callers) {
799
+ if (lookup.body.length > 0 || lookup.callers.length > 0) {
713
800
  return renderLookup(lookup, scope, cwd, worktree);
714
801
  }
715
802
 
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! │