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.
- package/README.md +181 -427
- package/package.json +2 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +31 -5
- package/src/adapters/hooks/edit-hint.ts +10 -0
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +10 -0
- package/src/adapters/hooks/stop.ts +347 -7
- package/src/adapters/mcp/tools/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +18 -7
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +142 -177
- package/src/install.ts +20 -8
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +13 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +139 -10
- package/src/seed/review-seed.ts +141 -54
- package/src/update.ts +60 -3
package/src/seed/plan-seed.ts
CHANGED
|
@@ -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 =
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 =
|
|
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
|
-
|
|
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);
|
package/src/seed/review-seed.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
throw new SeedError(`review-seed:
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
193
|
+
`review-seed: one scope flag at a time (got ${[...merged.keys()].join(", ")})\n${USAGE}`,
|
|
182
194
|
);
|
|
183
195
|
}
|
|
184
|
-
return
|
|
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
|
|
460
|
+
const planText = readFileSync(planPath, "utf-8");
|
|
461
|
+
const planFiles = planFrontFiles(planText);
|
|
398
462
|
if (planFiles === null) {
|
|
399
|
-
// No files[]
|
|
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:
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
`callers of ${
|
|
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
|
-
`
|
|
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)
|
|
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,
|
|
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! │
|