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.
Files changed (56) hide show
  1. package/README.md +201 -431
  2. package/package.json +1 -1
  3. package/skill/lookup-before-edit/SKILL.md +2 -2
  4. package/skill/plan-with-pony/SKILL.md +3 -1
  5. package/skill/review-pony/SKILL.md +2 -2
  6. package/src/adapters/cli.ts +1 -1
  7. package/src/adapters/hooks/bug-markers.ts +19 -9
  8. package/src/adapters/hooks/compute-hint-impact.ts +12 -1
  9. package/src/adapters/hooks/context-data.ts +32 -6
  10. package/src/adapters/hooks/edit-hint.ts +11 -1
  11. package/src/adapters/hooks/index.ts +15 -0
  12. package/src/adapters/hooks/read-hint.ts +11 -1
  13. package/src/adapters/hooks/stop.ts +347 -7
  14. package/src/adapters/mcp/primitives.ts +1 -1
  15. package/src/adapters/mcp/tools/check.ts +1 -1
  16. package/src/adapters/mcp/tools/collect.ts +1 -1
  17. package/src/adapters/mcp/tools/index.ts +13 -1
  18. package/src/adapters/mcp/tools/mem.ts +30 -5
  19. package/src/adapters/mcp/tools/report.ts +1 -1
  20. package/src/adapters/mcp/transport.ts +1 -1
  21. package/src/analyze/barrels.ts +56 -0
  22. package/src/analyze/blast.ts +58 -0
  23. package/src/analyze/cache.ts +162 -0
  24. package/src/analyze/cli.ts +28 -0
  25. package/src/analyze/criteria.ts +81 -0
  26. package/src/analyze/diagnose.ts +113 -0
  27. package/src/analyze/discover.ts +84 -0
  28. package/src/analyze/format.ts +38 -0
  29. package/src/analyze/graph.ts +111 -0
  30. package/src/analyze/index.ts +18 -0
  31. package/src/analyze/python.ts +411 -0
  32. package/src/analyze/resolve-ts.ts +39 -0
  33. package/src/analyze/types.ts +44 -0
  34. package/src/conventions-seed.ts +6 -2
  35. package/src/core/hint-log.ts +16 -1
  36. package/src/core/mem-log.ts +18 -0
  37. package/src/debt/cli.ts +25 -10
  38. package/src/debt/scan.ts +1 -1
  39. package/src/hook.ts +15 -0
  40. package/src/install/antigravity.ts +21 -8
  41. package/src/install/detect.ts +8 -5
  42. package/src/install/opencode.ts +6 -0
  43. package/src/install.ts +6 -4
  44. package/src/map.ts +220 -0
  45. package/src/mem/commands/plan.ts +70 -0
  46. package/src/mem/commands/read.ts +73 -18
  47. package/src/mem/commands/write.ts +75 -18
  48. package/src/mem/engine.ts +68 -2
  49. package/src/mem/index.ts +7 -5
  50. package/src/mem/render.ts +4 -1
  51. package/src/mem/selectors.ts +16 -0
  52. package/src/mem/store.ts +2 -0
  53. package/src/seed/plan-seed.ts +146 -13
  54. package/src/seed/review-seed.ts +144 -57
  55. package/templates/PLAN.md +1 -0
  56. package/src/analyze.ts +0 -688
@@ -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
@@ -34,7 +34,11 @@ import {
34
34
  writeFileSync,
35
35
  } from "node:fs";
36
36
  import { basename, join, relative, resolve, sep } from "node:path";
37
- import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "../analyze.js";
37
+ import {
38
+ collectSourceFiles,
39
+ isSkippedDir,
40
+ SCAN_EXTS,
41
+ } from "../analyze/index.js";
38
42
  import {
39
43
  CONFIG_FILENAME,
40
44
  type Config,
@@ -43,8 +47,9 @@ import {
43
47
  planDir,
44
48
  specDir,
45
49
  } from "../core/config.js";
50
+ import { MEM_TEXT_MAX } from "../core/mem-log.js";
46
51
  import { extractExports } from "../map.js";
47
- import { readRecentMemDecisions } from "../memory.js";
52
+ import { readMemLog, readRecentMemDecisions } from "../memory.js";
48
53
  import { capLines, execGit, SIG_MAX } from "./primitives.js";
49
54
 
50
55
  // One chunk = one module's signatures — past ~40 lines a module is its own
@@ -220,7 +225,7 @@ function renderExistingInScope(
220
225
  } catch {
221
226
  continue;
222
227
  }
223
- const scan = extractExports(source);
228
+ const scan = extractExports(source, undefined, rel);
224
229
  if (scan.error || scan.symbols.length === 0) continue;
225
230
  // Re-export-only files scan as one `*` per line — dedupe to a single `*`.
226
231
  const names = [
@@ -301,6 +306,7 @@ One step = one chunk = one session: finish it, close it, **stop** — starting t
301
306
  1. _(agent fills in — each step must be verifiable)_
302
307
 
303
308
  **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).
309
+ - [ ] handoff: the mem note is the handoff — this box only opts the plan into the Stop-hook check
304
310
 
305
311
  ## 7. Examples
306
312
  ${
@@ -345,7 +351,7 @@ function fileLines(absFile: string): string[] {
345
351
  } catch {
346
352
  return ["_(unreadable)_"];
347
353
  }
348
- const scan = extractExports(source);
354
+ const scan = extractExports(source, undefined, absFile);
349
355
  if (scan.error) return [`⚠ ${scan.error} — symbols not extractable`];
350
356
  if (scan.symbols.length === 0) return ["_(no exports)_"];
351
357
  let srcLines: string[] = [];
@@ -507,6 +513,7 @@ function specTemplate(
507
513
  name: string,
508
514
  chunks: Chunk[],
509
515
  scopeEcho: string | null,
516
+ traps: string[],
510
517
  ): string {
511
518
  const index = chunks.map((c) => `- [${c.title}](#${c.slug})`).join("\n");
512
519
  // The index is one string with a newline per chunk; budgeting it as one line
@@ -534,12 +541,18 @@ function specTemplate(
534
541
  const tail = [
535
542
  "## (agent fills in — wireframes / edge cases / API shapes the plan references)",
536
543
  ];
537
- const bodyLines = chunks.flatMap((c) => [
538
- `## <a id="${c.slug}"></a>${c.title}`,
539
- "",
540
- ...c.body.split("\n"),
541
- "",
542
- ]);
544
+ const bodyLines = [
545
+ // Known traps sit after the index, before the first chunk body — budgeted
546
+ // with the bodies so the whole-SPEC cap still cuts from the tail and the
547
+ // count line above survives (a silent section would teach skipping it).
548
+ ...(traps.length > 0 ? [...traps, ""] : []),
549
+ ...chunks.flatMap((c) => [
550
+ `## <a id="${c.slug}"></a>${c.title}`,
551
+ "",
552
+ ...c.body.split("\n"),
553
+ "",
554
+ ]),
555
+ ];
543
556
  // Whole-file cap runs last and the agent section survives it, same way
544
557
  // review-seed reserves its disclaimer: reserve the tail, cut the middle,
545
558
  // say how much was dropped.
@@ -552,10 +565,107 @@ function specTemplate(
552
565
  return `${[...head, ...cappedBody, ...tail].join("\n")}\n`;
553
566
  }
554
567
 
568
+ // --- Known traps (PLAN-active-pain chunk 2): mem rows on this scope ---
569
+ //
570
+ // The one place a seed is allowed to be opinionated: past pain about exactly
571
+ // these files. bug rows first, then decision (note carries no "this hurt"
572
+ // signal), newest first within a kind. Match mirrors mem_find: files[] first;
573
+ // the text/spec fallback runs ONLY for rows with no files[] at all — a row
574
+ // that named files already spoke, its text may quote any path.
575
+ //
576
+ // Measured base rate on this repo before building (rule 2, 2026-09-23):
577
+ // 248 rows total; scope src/seed → 5 matched (3 bug/2 decision, 0 lacked) ·
578
+ // src/mem → 12 (4/8, 0) · src/adapters/hooks → 6 (2/4, 0) · whole repo → 61
579
+ // (20/41, 0). The "M lacked files[]" fallback layer fired 0/4 scopes here —
580
+ // kept because a pre-files[] repo (vela's 2,672 rows) depends on it.
581
+ const MAX_TRAP_ROWS = 5;
582
+
583
+ interface TrapHit {
584
+ row: {
585
+ ts: string;
586
+ kind: string;
587
+ text: string;
588
+ files?: string[];
589
+ spec?: string;
590
+ };
591
+ /** First in-scope file[] entry that matched — null for the text fallback. */
592
+ file: string | null;
593
+ viaText: boolean;
594
+ }
595
+
596
+ export function renderKnownTraps(
597
+ worktree: string,
598
+ cwd: string,
599
+ roots: string[],
600
+ scoped: boolean,
601
+ ): { lines: string[]; matched: number; lacked: number } {
602
+ const empty = { lines: [], matched: 0, lacked: 0 };
603
+ try {
604
+ const { rows, filesFound } = readMemLog(worktree);
605
+ if (filesFound === 0 || rows.length === 0) return empty;
606
+
607
+ const scopeFiles = new Set<string>();
608
+ for (const r of roots)
609
+ for (const f of scopeSourceFiles(r)) scopeFiles.add(relative(cwd, f));
610
+ if (scopeFiles.size === 0) return empty;
611
+ const scopeKeys = roots
612
+ .map((r) => relative(cwd, r))
613
+ .filter((k) => k !== "" && k !== ".");
614
+
615
+ const hits: TrapHit[] = [];
616
+ for (const row of rows) {
617
+ if (row.kind !== "bug" && row.kind !== "decision") continue;
618
+ const files = row.files ?? [];
619
+ const inScope = files.find((f) => scopeFiles.has(f));
620
+ if (inScope) {
621
+ hits.push({ row, file: inScope, viaText: false });
622
+ continue;
623
+ }
624
+ // Fallback: only a row with NO files[] — see comment above.
625
+ if (files.length === 0 && scoped) {
626
+ const hay = `${row.text}\n${row.spec ?? ""}`;
627
+ if (scopeKeys.some((k) => hay.includes(k))) {
628
+ hits.push({ row, file: null, viaText: true });
629
+ }
630
+ }
631
+ }
632
+ if (hits.length === 0) return empty;
633
+
634
+ // Stable sort: bug before decision, recency preserved inside each kind.
635
+ hits.sort((a, b) =>
636
+ a.row.kind === b.row.kind ? 0 : a.row.kind === "bug" ? -1 : 1,
637
+ );
638
+ const lacked = hits.filter((h) => h.viaText).length;
639
+ const lines = [
640
+ "## Known traps (fapony mem)",
641
+ "",
642
+ `- ${hits.length} relevant row(s) on this scope (${lacked} lacked files[]${lacked > 0 ? " — matched via text" : ""})`,
643
+ ];
644
+ for (const h of hits.slice(0, MAX_TRAP_ROWS)) {
645
+ const text =
646
+ h.row.text.length > MEM_TEXT_MAX
647
+ ? `${h.row.text.slice(0, MEM_TEXT_MAX - 1)}…`
648
+ : h.row.text;
649
+ lines.push(
650
+ `- ${h.row.ts.slice(0, 10)} ${h.row.kind} — ${text}${h.file ? ` (${h.file})` : ""}`,
651
+ );
652
+ }
653
+ if (hits.length > MAX_TRAP_ROWS) {
654
+ lines.push(
655
+ `- … +${hits.length - MAX_TRAP_ROWS} more at cap ${MAX_TRAP_ROWS} (bug first, then decision)`,
656
+ );
657
+ }
658
+ return { lines, matched: hits.length, lacked };
659
+ } catch {
660
+ return empty; // no mem log / unreadable = a seed with no traps, never an error
661
+ }
662
+ }
663
+
555
664
  // --- CLI entry ---
556
665
 
557
666
  export function cmdPlanSeed(args: string[]): void {
558
- const usage = "usage: fapony plan-seed <name> [--spec] [--scope <path>]...";
667
+ const usage =
668
+ "usage: fapony plan-seed <name> [--spec] [--scope <path>[,<path>]]...";
559
669
  // Positional parse, not args.find(!startsWith("--")) — a --scope VALUE is
560
670
  // a non-flag argument and must never be mistaken for the plan name.
561
671
  let name: string | undefined;
@@ -571,7 +681,20 @@ export function cmdPlanSeed(args: string[]): void {
571
681
  console.error(`plan-seed: --scope needs a path\n${usage}`);
572
682
  process.exit(1);
573
683
  }
574
- scopeArgs.push(v);
684
+ // Agents expect `--scope a,b,c` — same comma shape as `--files` — so a
685
+ // comma list is split here instead of failing on a path that never is.
686
+ // A real repo dir can't contain a literal ",x" tail; any comma splits.
687
+ // Trim each segment too: `a, b` is the same habit as `a,b`, and the
688
+ // space must not become part of the path (PLAN-comma-x).
689
+ const syms = v
690
+ .split(",")
691
+ .map((s) => s.trim())
692
+ .filter(Boolean);
693
+ if (syms.length === 0) {
694
+ console.error(`plan-seed: --scope needs a path\n${usage}`);
695
+ process.exit(1);
696
+ }
697
+ scopeArgs.push(...syms);
575
698
  i++;
576
699
  } else if (a.startsWith("--")) {
577
700
  console.error(`plan-seed: unknown flag "${a}"\n${usage}`);
@@ -643,6 +766,7 @@ export function cmdPlanSeed(args: string[]): void {
643
766
  );
644
767
 
645
768
  let specLink: string | null = null;
769
+ let traps = { lines: [] as string[], matched: 0, lacked: 0 };
646
770
  if (withSpec) {
647
771
  const specDirAbs = join(cwd, specDir());
648
772
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
@@ -652,6 +776,7 @@ export function cmdPlanSeed(args: string[]): void {
652
776
  );
653
777
  process.exit(1);
654
778
  }
779
+ traps = renderKnownTraps(worktree, cwd, roots, scoped);
655
780
  const chunks = buildChunks(roots, cwd);
656
781
  mkdirSync(specDirAbs, { recursive: true });
657
782
  writeFileSync(
@@ -662,6 +787,7 @@ export function cmdPlanSeed(args: string[]): void {
662
787
  requested.length > 0
663
788
  ? roots.map((r) => relative(cwd, r) || ".").join(", ")
664
789
  : null,
790
+ traps.lines,
665
791
  ),
666
792
  );
667
793
  specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
@@ -691,6 +817,13 @@ export function cmdPlanSeed(args: string[]): void {
691
817
  }
692
818
  writeFileSync(planPath, planBody);
693
819
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
820
+ // Measurement surface (plan §3): a silent section is indistinguishable from
821
+ // "no mem log" — say what was injected so a week of seeds is countable.
822
+ if (traps.matched > 0) {
823
+ console.log(
824
+ `Known traps: ${traps.matched} row(s) injected (${traps.lacked} lacked files[])`,
825
+ );
826
+ }
694
827
  console.log("Existing plans:");
695
828
  for (const l of listExistingPlans(cwd, config, `PLAN-${name}.md`))
696
829
  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,
@@ -29,7 +29,7 @@ import {
29
29
  isTestedThroughBarrels,
30
30
  isTestFile,
31
31
  SCAN_EXTS,
32
- } from "../analyze.js";
32
+ } from "../analyze/index.js";
33
33
  import { extractBody, extractExports } from "../map.js";
34
34
  import { assertSafe } from "../safety.js";
35
35
  import { execGit, gitOk, gitValue, SeedError, SIG_MAX } from "./primitives.js";
@@ -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
 
@@ -626,7 +707,7 @@ function renderLookup(
626
707
  } catch {
627
708
  continue;
628
709
  }
629
- const scan = extractExports(source);
710
+ const scan = extractExports(source, undefined, path);
630
711
  if (scan.error) continue;
631
712
  for (const sym of scan.symbols) {
632
713
  if (!flags.body.includes(sym.name)) continue;
@@ -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
 
@@ -815,7 +902,7 @@ export function renderSeed(args: string[], cwd: string): string {
815
902
  } catch {
816
903
  continue;
817
904
  }
818
- const scan = extractExports(source);
905
+ const scan = extractExports(source, undefined, e.path);
819
906
  if (scan.error) {
820
907
  sigLines.push(` ${e.path} — ⚠ ${scan.error}`);
821
908
  continue;
package/templates/PLAN.md CHANGED
@@ -51,6 +51,7 @@ Table with 3–5 rows: risk | likelihood | impact | escape hatch
51
51
  1. **<Step 1>** — has a clear deliverable
52
52
  2. **<Step 2>** — ...
53
53
  Each step must be verifiable before moving to the next
54
+ A step needing state the system doesn't store yet must say where it lives, who writes it, who reads it
54
55
 
55
56
  ## 7. Examples (make it concrete)
56
57
  bash examples: before / after — **link into spec/, don't paste it.**