fapony 0.3.5 → 0.3.6

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.
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
+ import { parseSince } from "../../core/since.js";
5
6
  import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
7
+ import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
6
8
  import { doneLines, fmtClose, fmtRow } from "../render.js";
7
9
  import { claimsOf, openRows, staleReport } from "../selectors.js";
8
10
  import type { CloseRow, WorkRow } from "../store.js";
@@ -65,31 +67,133 @@ export const cmdStale = () => {
65
67
  };
66
68
 
67
69
  export const cmdFind = (a: string[]) => {
68
- // mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
69
- const q = a.join(" ").toLowerCase();
70
- if (!q) {
71
- console.error(`usage: ${memCmd} find <word>`);
70
+ // mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]
71
+ // Query logic lives in the shared engine (../engine.ts) — this wrapper owns
72
+ // only argv parsing + the single-line print. MCP memFind calls the same
73
+ // engine with no kind default (contract); CLI keeps its legacy default of
74
+ // hiding bookkeeping rows unless --kind names them (PLAN-unify-mem-engine).
75
+ const flagVal = (flag: string, i: number): string | undefined => {
76
+ const t = a[i];
77
+ const eq = `${flag}=`;
78
+ if (t.startsWith(eq)) return t.slice(eq.length);
79
+ return a[i + 1];
80
+ };
81
+ const consumed = (flag: string, i: number): number => {
82
+ if (a[i].startsWith(`${flag}=`)) return 1;
83
+ return 2;
84
+ };
85
+
86
+ let kind: string[] | undefined;
87
+ let files: string[] | undefined;
88
+ let sinceRaw: string | undefined;
89
+ let limit: number | undefined;
90
+ let open = false;
91
+ const positional: string[] = [];
92
+
93
+ for (let i = 0; i < a.length; ) {
94
+ const t = a[i];
95
+ if (t === "--kind" || t.startsWith("--kind=")) {
96
+ const v = flagVal("--kind", i);
97
+ if (!v || v.startsWith("--")) {
98
+ console.error(
99
+ `--kind needs a value — usage: ${memCmd} find "<text>" --kind bug,decision`,
100
+ );
101
+ process.exit(1);
102
+ }
103
+ kind = v
104
+ .split(",")
105
+ .map((s) => s.trim())
106
+ .filter(Boolean);
107
+ i += consumed("--kind", i);
108
+ } else if (t === "--files" || t.startsWith("--files=")) {
109
+ const v = flagVal("--files", i);
110
+ if (!v || v.startsWith("--")) {
111
+ console.error(
112
+ `--files needs a value — usage: ${memCmd} find --files path/to/file.ts[,more]`,
113
+ );
114
+ process.exit(1);
115
+ }
116
+ files = v
117
+ .split(",")
118
+ .map((s) => s.trim().replace(/^\.\//, ""))
119
+ .filter(Boolean);
120
+ i += consumed("--files", i);
121
+ } else if (t === "--since" || t.startsWith("--since=")) {
122
+ const v = flagVal("--since", i);
123
+ if (!v || v.startsWith("--")) {
124
+ console.error(`--since needs a value — use <N>d or YYYY-MM-DD`);
125
+ process.exit(1);
126
+ }
127
+ sinceRaw = v;
128
+ i += consumed("--since", i);
129
+ } else if (t === "--limit" || t.startsWith("--limit=")) {
130
+ const v = flagVal("--limit", i);
131
+ const n = Number(v);
132
+ if (!v || v.startsWith("--") || !Number.isInteger(n) || n < 0) {
133
+ console.error(
134
+ `--limit needs a non-negative integer — got "${v ?? ""}"`,
135
+ );
136
+ process.exit(1);
137
+ }
138
+ limit = n;
139
+ i += consumed("--limit", i);
140
+ } else if (t === "--open") {
141
+ open = true;
142
+ i += 1;
143
+ } else {
144
+ positional.push(t);
145
+ i += 1;
146
+ }
147
+ }
148
+
149
+ const q = positional.join(" ").trim();
150
+ if (
151
+ !q &&
152
+ !kind?.length &&
153
+ !files?.length &&
154
+ !sinceRaw &&
155
+ limit === undefined &&
156
+ !open
157
+ ) {
158
+ console.error(
159
+ `usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]`,
160
+ );
72
161
  process.exit(1);
73
162
  }
163
+
164
+ let sinceIso: string | undefined;
165
+ if (sinceRaw) {
166
+ try {
167
+ sinceIso = parseSince(sinceRaw).iso;
168
+ } catch (e) {
169
+ console.error(e instanceof Error ? e.message : String(e));
170
+ process.exit(1);
171
+ }
172
+ }
173
+
74
174
  // allRows, not rows: find is recall — rotated history counts
75
- const hits = allRows()
76
- .filter(
77
- (r): r is WorkRow =>
78
- r.kind !== "close" &&
79
- r.kind !== "synced" &&
80
- r.kind !== "claim" &&
81
- r.kind !== "release",
82
- )
83
- .filter(
84
- (r) =>
85
- r.text.toLowerCase().includes(q) ||
86
- (r.spec ?? "").toLowerCase().includes(q),
87
- )
88
- .slice(-20);
89
- for (const r of hits)
90
- console.log(
91
- `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text}${r.spec ? ` → ${r.spec}` : ""}`,
92
- );
175
+ const { rows: newest } = engineFind(allRows(), {
176
+ text: q || undefined,
177
+ files,
178
+ kind,
179
+ excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
180
+ sinceIso,
181
+ limit,
182
+ open: open || undefined,
183
+ });
184
+ // Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
185
+ const hits = [...newest].reverse();
186
+ for (const r of hits) {
187
+ if ("id" in r) {
188
+ console.log(
189
+ `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
190
+ );
191
+ } else {
192
+ const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
193
+ const body = ("text" in r && r.text) || ("spec" in r && r.spec) || "";
194
+ console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
195
+ }
196
+ }
93
197
  if (!hits.length) console.log("(no matches)");
94
198
  };
95
199
 
@@ -332,7 +436,7 @@ export const cmdKickoff = (a: string[]) => {
332
436
  const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
333
437
  if (uniqueRecent.length) {
334
438
  suggestions.push({
335
- text: `แตะล่าสุด: ${uniqueRecent.join(", ")}`,
439
+ text: `Recently touched: ${uniqueRecent.join(", ")}`,
336
440
  run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
337
441
  });
338
442
  }
@@ -341,7 +445,7 @@ export const cmdKickoff = (a: string[]) => {
341
445
  try {
342
446
  if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
343
447
  suggestions.push({
344
- text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
448
+ text: "lint baseline not captured yet — pre-existing red will count as yours",
345
449
  run: "fapony lint-baseline --capture",
346
450
  });
347
451
  }
@@ -1,8 +1,9 @@
1
1
  // commands/write.ts — mutating commands: add, close, claim, release, synced, hook
2
2
 
3
+ import { CapError, engineAdd, engineClose } from "../engine.js";
3
4
  import { claimsOf, openRows } from "../selectors.js";
4
5
  import type { WorkKind } from "../store.js";
5
- import { KINDS, memCmd, nextId, put, root, rows } from "../store.js";
6
+ import { KINDS, memCmd, put, root, rows } from "../store.js";
6
7
 
7
8
  export const cmdAdd = async (a: string[]) => {
8
9
  // mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
@@ -65,33 +66,23 @@ export const cmdAdd = async (a: string[]) => {
65
66
  }
66
67
  text = filtered.join(" ");
67
68
  }
68
- // read once — cap check + id collision
69
- const all = rows();
70
- // ponytail: a cap on open next/hold stops endless accumulation — forces triage of the old before opening new
71
- const CAP = 15;
72
- const CAP_HOLD = 10;
73
- if (a[0] === "next" && !process.env.MEM_FORCE) {
74
- const openNext = openRows(all).filter((r) => r.kind === "next").length;
75
- if (openNext >= CAP) {
76
- console.error(
77
- `open next ${openNext}/${CAP} is full — close an old one first (or MEM_FORCE=1 if you really must)`,
78
- );
79
- process.exit(1);
80
- }
81
- }
82
- if (a[0] === "hold" && !process.env.MEM_FORCE) {
83
- const openHold = openRows(all).filter((r) => r.kind === "hold").length;
84
- if (openHold >= CAP_HOLD) {
85
- console.error(
86
- `open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first (or MEM_FORCE=1)`,
87
- );
88
- process.exit(1);
69
+ // Domain rules (caps, id) live in the shared engine — this wrapper owns
70
+ // only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
71
+ try {
72
+ const { id } = engineAdd({ kind: a[0], text, spec, files });
73
+ console.log(id);
74
+ } catch (e) {
75
+ if (e instanceof CapError) {
76
+ const hint =
77
+ e.cap === "next"
78
+ ? " (or MEM_FORCE=1 if you really must)"
79
+ : " (or MEM_FORCE=1)";
80
+ console.error(`${e.message}${hint}`);
81
+ } else {
82
+ console.error(e instanceof Error ? e.message : String(e));
89
83
  }
84
+ process.exit(1);
90
85
  }
91
- // ponytail: prevent id collisions — logic centralized in nextId (store.ts)
92
- const id = nextId(all);
93
- put({ id, kind: a[0] as WorkKind, text, spec, files });
94
- console.log(id);
95
86
  };
96
87
 
97
88
  export const cmdClose = async (a: string[]) => {
@@ -101,10 +92,6 @@ export const cmdClose = async (a: string[]) => {
101
92
  console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
102
93
  process.exit(1);
103
94
  }
104
- if (!rows().some((r) => "id" in r && r.id === a[0])) {
105
- console.error(`no id "${a[0]}" in the log`);
106
- process.exit(1);
107
- }
108
95
  const rest = a.slice(1);
109
96
  const useStdin = rest.includes("--stdin");
110
97
  let text: string;
@@ -113,7 +100,15 @@ export const cmdClose = async (a: string[]) => {
113
100
  } else {
114
101
  text = rest.join(" ");
115
102
  }
116
- put({ kind: "close", ref: a[0], text });
103
+ // Tombstone rules (id must exist, text required) live in the shared engine —
104
+ // note this tightens CLI: an empty-text close now errors instead of writing
105
+ // a textless tombstone (intentional, PLAN-unify-mem-engine §4).
106
+ try {
107
+ engineClose({ id: a[0], text });
108
+ } catch (e) {
109
+ console.error(e instanceof Error ? e.message : String(e));
110
+ process.exit(1);
111
+ }
117
112
  };
118
113
 
119
114
  export const cmdClaim = (a: string[]) => {
@@ -0,0 +1,249 @@
1
+ // src/mem/engine.ts — the single validation+write engine for mem add/close.
2
+ //
3
+ // Both entry points call it: CLI (commands/write.ts cmdAdd/cmdClose) and MCP
4
+ // (adapters/mcp/tools/mem.ts memAdd/memClose). Every domain rule lives here
5
+ // exactly once — kind check, files[] required, hold-requires-spec, open
6
+ // next/hold caps. Wrappers own only their surface: argv parsing +
7
+ // console.error/exit (CLI), worktree-shape checks + JSON results (MCP).
8
+ //
9
+ // Precondition: the store is initialized (initStore) before calling — cli.ts
10
+ // does it at dispatch, the MCP wrapper does it per call with its worktree.
11
+ // (PLAN-unify-mem-engine chunk 1)
12
+
13
+ import { openRows } from "./selectors.js";
14
+ import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
15
+
16
+ export const CAP_NEXT = 15;
17
+ export const CAP_HOLD = 10;
18
+
19
+ /**
20
+ * Thrown when an open next/hold cap is full. Carries which cap so the CLI
21
+ * wrapper can append its MEM_FORCE hint while MCP passes the bare message
22
+ * (the two surfaces keep their exact legacy strings — PLAN §4).
23
+ */
24
+ export class CapError extends Error {
25
+ readonly cap: "next" | "hold";
26
+ constructor(cap: "next" | "hold", message: string) {
27
+ super(message);
28
+ this.name = "CapError";
29
+ this.cap = cap;
30
+ }
31
+ }
32
+
33
+ export interface EngineAddArgs {
34
+ kind: string;
35
+ text: string;
36
+ files: string[];
37
+ spec?: string;
38
+ }
39
+
40
+ export interface EngineAddResult {
41
+ id: string;
42
+ kind: string;
43
+ text: string;
44
+ files: string[];
45
+ spec?: string;
46
+ ts: string;
47
+ }
48
+
49
+ export function engineAdd(a: EngineAddArgs): EngineAddResult {
50
+ if (!KINDS.includes(a.kind as WorkKind)) {
51
+ throw new Error(`kind must be one of ${KINDS.join("|")} — got "${a.kind}"`);
52
+ }
53
+ if (a.files.length === 0) {
54
+ throw new Error("files must contain at least one path");
55
+ }
56
+ if (!a.text.trim()) {
57
+ throw new Error("text is required and must not be empty");
58
+ }
59
+ // Without a spec a hold can never be resolved by rotate — reject at write.
60
+ if (a.kind === "hold" && !a.spec) {
61
+ throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
62
+ }
63
+
64
+ const all = rows();
65
+ if (a.kind === "next" && !process.env.MEM_FORCE) {
66
+ const openNext = openRows(all).filter((r) => r.kind === "next").length;
67
+ if (openNext >= CAP_NEXT) {
68
+ throw new CapError(
69
+ "next",
70
+ `open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
71
+ );
72
+ }
73
+ }
74
+ if (a.kind === "hold" && !process.env.MEM_FORCE) {
75
+ const openHold = openRows(all).filter((r) => r.kind === "hold").length;
76
+ if (openHold >= CAP_HOLD) {
77
+ throw new CapError(
78
+ "hold",
79
+ `open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
80
+ );
81
+ }
82
+ }
83
+
84
+ const id = nextId(all);
85
+ const ts = new Date().toISOString();
86
+ put({
87
+ id,
88
+ kind: a.kind as WorkKind,
89
+ text: a.text,
90
+ spec: a.spec,
91
+ files: a.files,
92
+ });
93
+ return { id, kind: a.kind, text: a.text, files: a.files, spec: a.spec, ts };
94
+ }
95
+
96
+ export interface EngineCloseArgs {
97
+ id: string;
98
+ text: string;
99
+ }
100
+
101
+ export interface EngineCloseResult {
102
+ ref: string;
103
+ text: string;
104
+ ts: string;
105
+ }
106
+
107
+ export function engineClose(a: EngineCloseArgs): EngineCloseResult {
108
+ if (!a.id.trim()) {
109
+ throw new Error("id is required");
110
+ }
111
+ if (!a.text.trim()) {
112
+ throw new Error("text is required and must not be empty");
113
+ }
114
+
115
+ const all = rows();
116
+ if (!all.some((r) => "id" in r && r.id === a.id)) {
117
+ throw new Error(`no id "${a.id}" in the log`);
118
+ }
119
+
120
+ const ts = new Date().toISOString();
121
+ put({ kind: "close", ref: a.id, text: a.text });
122
+
123
+ return { ref: a.id, text: a.text, ts };
124
+ }
125
+
126
+ // --- find ---
127
+ //
128
+ // The single query engine for mem find. Both entry points call it with rows
129
+ // they already read — CLI (commands/read.ts cmdFind, via allRows() which
130
+ // includes rotated archives) and MCP (adapters/mcp/tools/mem.ts memFind, via
131
+ // readMemLog whose loose log*.jsonl regex includes log.YYYY-MM-DD.jsonl too).
132
+ // Archive scope is therefore identical on both sides with no parameter for it
133
+ // (PLAN-unify-mem-engine chunk 2 §4.1: the "MCP reads live only" premise was
134
+ // wrong — both see archives, base rate 0 archive files vs 211 live rows).
135
+ //
136
+ // The engine is pure: it takes rows + typed params, never touches argv
137
+ // strings or the store. Wrappers own their surface — CLI parses
138
+ // --kind/--files/--since/--limit/--open at the argv layer, MCP validates its JSON
139
+ // shape — and each passes its own kind default (PLAN §5 escape):
140
+ // MCP passes no exclude (contract: "every kind, no default filter", locked by
141
+ // test), CLI passes the bookkeeping exclude to keep its legacy output.
142
+
143
+ export const FIND_DEFAULT_LIMIT = 20;
144
+
145
+ /** Bookkeeping rows the CLI hides unless explicitly asked via --kind. */
146
+ export const CLI_FIND_EXCLUDE = ["close", "synced", "claim", "release"];
147
+
148
+ export interface EngineFindArgs {
149
+ /** Substring over text/spec/ref, case-insensitive. Omit = no text filter. */
150
+ text?: string;
151
+ /** Repo-relative paths — stored files[] first, text/spec/ref fallback. */
152
+ files?: string[];
153
+ /** Include filter — when non-empty, wins over excludeKind. */
154
+ kind?: string[];
155
+ /** Exclude filter — applied only when kind is empty. */
156
+ excludeKind?: string[];
157
+ /** ISO timestamp — only rows at or after this time (inclusive). */
158
+ sinceIso?: string;
159
+ /** Max rows returned (total still counts all matches). Default 20. */
160
+ limit?: number;
161
+ /**
162
+ * true = unresolved work only: drops bookkeeping kinds
163
+ * (close/claim/release/synced) plus work rows a close row points at.
164
+ * Mirrors selectors.openRows but stays generic (no store types) so MCP rows
165
+ * qualify. Default false — recall shows closed rows too.
166
+ */
167
+ open?: boolean;
168
+ }
169
+
170
+ export interface EngineFindResult<T> {
171
+ rows: T[];
172
+ total: number;
173
+ }
174
+
175
+ export type FindableRow = {
176
+ kind: string;
177
+ text?: string;
178
+ spec?: string;
179
+ ref?: string;
180
+ files?: string[];
181
+ ts: string;
182
+ };
183
+
184
+ export function engineFind<T extends FindableRow>(
185
+ all: T[],
186
+ a: EngineFindArgs,
187
+ ): EngineFindResult<T> {
188
+ let out = [...all];
189
+
190
+ if (a.open === true) {
191
+ const dead = new Set(
192
+ all
193
+ .filter((r) => r.kind === "close" && typeof r.ref === "string")
194
+ .map((r) => r.ref as string),
195
+ );
196
+ const BOOKKEEPING = new Set(["close", "claim", "release", "synced"]);
197
+ out = out.filter(
198
+ (r) =>
199
+ !BOOKKEEPING.has(r.kind) &&
200
+ !("id" in r && typeof r.id === "string" && dead.has(r.id)),
201
+ );
202
+ }
203
+
204
+ if (a.sinceIso) {
205
+ const since = a.sinceIso;
206
+ out = out.filter((r) => !(r.ts < since));
207
+ }
208
+
209
+ if (a.kind && a.kind.length > 0) {
210
+ const keep = new Set(a.kind);
211
+ out = out.filter((r) => keep.has(r.kind));
212
+ } else if (a.excludeKind && a.excludeKind.length > 0) {
213
+ const drop = new Set(a.excludeKind);
214
+ out = out.filter((r) => !drop.has(r.kind));
215
+ }
216
+
217
+ if (a.text?.trim()) {
218
+ const needle = a.text.toLowerCase();
219
+ out = out.filter((r) =>
220
+ `${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`
221
+ .toLowerCase()
222
+ .includes(needle),
223
+ );
224
+ }
225
+
226
+ if (a.files && a.files.length > 0) {
227
+ // Rows written by `mem add --files` carry files[] — match that first.
228
+ // Older rows (and any row whose author skipped --files) have none, so the
229
+ // text/spec/ref substring stays as the fallback: low recall by nature,
230
+ // a limit of the data rather than of the query.
231
+ const paths = a.files.map((f) => f.toLowerCase());
232
+ out = out.filter((r) => {
233
+ const stored = (r.files ?? []).map((f) => f.toLowerCase());
234
+ if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
235
+ return true;
236
+ const hay =
237
+ `${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
238
+ return paths.some((p) => hay.includes(p));
239
+ });
240
+ }
241
+
242
+ // Newest first — the canonical order. The CLI wrapper reprints oldest-first
243
+ // to keep its legacy output byte-identical (same set, legacy order).
244
+ out.sort((x, y) => y.ts.localeCompare(x.ts));
245
+
246
+ const total = out.length;
247
+ const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
248
+ return { rows: out.slice(0, limit), total };
249
+ }
package/src/mem/index.ts CHANGED
@@ -27,7 +27,8 @@ subcommands:
27
27
  kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
28
28
  add <kind> "<text>" --files f1,f2 [spec.md]
29
29
  close <id> "<msg>" close a bug
30
- find "<text>" substring-search every row
30
+ find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
31
+ substring-search every row (archives included)
31
32
  done | stale views
32
33
  claim <id> | release <id> | synced bookkeeping
33
34
  plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
@@ -57,9 +58,13 @@ example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts`,
57
58
  tombstone a bug so it stops showing as open work
58
59
  --stdin read the message from stdin
59
60
  example: fapony mem close mt14 "fixed in a2c6beb"`,
60
- find: `usage: fapony mem find "<text>"
61
- substring search over every row's text/spec/ref — all kinds, no default filter
62
- example: fapony mem find "usage-web"`,
61
+ find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
62
+ substring search over every row's text/spec/ref (rotated archives included) — bookkeeping kinds (close/synced/claim/release) hidden unless --kind names them
63
+ --kind a,b include only these kinds (overrides the bookkeeping default)
64
+ --files f1,f2 rows about these paths (stored files[] first, text/spec/ref fallback)
65
+ --since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
66
+ --limit n max rows returned (default 20, newest first)
67
+ example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
63
68
  done: `usage: fapony mem done
64
69
  closed rows with their tombstone message
65
70
  example: fapony mem done`,
@@ -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 { join } from "node:path";
24
+ import { isAbsolute, join, resolve } from "node:path";
25
25
  import {
26
26
  buildGraph,
27
27
  collectSourceFiles,
@@ -357,9 +357,35 @@ function shortSha(cwd: string, ref: string): string | null {
357
357
  return r.ok ? r.output.split("\n")[0] : null;
358
358
  }
359
359
 
360
- function resolveScope(scope: Scope, cwd: string): ResolvedScope {
360
+ // --plan paths arrive in three shapes: absolute, relative to the invocation
361
+ // cwd (e.g. ../../.fapony/plan/X.md from a subdir), or relative to the
362
+ // worktree root (e.g. .fapony/plan/X.md from anywhere). join(worktree, arg)
363
+ // answers only the third — an absolute arg becomes worktree+abs garbage
364
+ // (join, unlike resolve, does not reset on an absolute segment), and a
365
+ // cwd-relative arg with .. escapes above the root. So: absolute as-is,
366
+ // otherwise first-existing-wins between cwd and worktree (bug mucm1own).
367
+ function resolvePlanPath(arg: string, cwd: string, worktree: string): string {
368
+ if (isAbsolute(arg)) {
369
+ if (existsSync(arg)) return arg;
370
+ throw new SeedError(`review-seed: plan file not found: ${arg}`);
371
+ }
372
+ const candidates = [resolve(cwd, arg), join(worktree, arg)];
373
+ for (const p of new Set(candidates)) {
374
+ if (existsSync(p)) return p;
375
+ }
376
+ throw new SeedError(`review-seed: plan file not found: ${arg}`);
377
+ }
378
+
379
+ function resolveScope(
380
+ scope: Scope,
381
+ cwd: string,
382
+ worktree: string,
383
+ ): ResolvedScope {
361
384
  if (scope.kind === "files") {
362
- const { files, notes, dirExpanded } = expandFilesScope(scope.list, cwd);
385
+ const { files, notes, dirExpanded } = expandFilesScope(
386
+ scope.list,
387
+ worktree,
388
+ );
363
389
  return {
364
390
  label: dirExpanded ? "--files (dir-expanded)" : "--files (as given)",
365
391
  entries: files,
@@ -367,10 +393,7 @@ function resolveScope(scope: Scope, cwd: string): ResolvedScope {
367
393
  };
368
394
  }
369
395
  if (scope.kind === "plan") {
370
- const planPath = join(cwd, scope.path);
371
- if (!existsSync(planPath)) {
372
- throw new SeedError(`review-seed: plan file not found: ${scope.path}`);
373
- }
396
+ const planPath = resolvePlanPath(scope.path, cwd, worktree);
374
397
  const planFiles = planFrontFiles(readFileSync(planPath, "utf-8"));
375
398
  if (planFiles === null) {
376
399
  // No files[] to scope from — fall back to the default diff, say so.
@@ -567,6 +590,7 @@ function findCallers(
567
590
  function renderLookup(
568
591
  flags: LookupFlags,
569
592
  scope: Scope,
593
+ cwd: string,
570
594
  worktree: string,
571
595
  ): string {
572
596
  const lines: string[] = [];
@@ -574,7 +598,7 @@ function renderLookup(
574
598
 
575
599
  let resolved: ResolvedScope | null = null;
576
600
  if (flags.callers) {
577
- resolved = resolveScope(scope, worktree);
601
+ resolved = resolveScope(scope, cwd, worktree);
578
602
  }
579
603
 
580
604
  const hasGraph = (f: string): boolean => {
@@ -686,10 +710,10 @@ export function renderSeed(args: string[], cwd: string): string {
686
710
  // the caller asked for one answer, not the review seed around it.
687
711
  const lookup = parseLookup(args);
688
712
  if (lookup.body.length > 0 || lookup.callers) {
689
- return renderLookup(lookup, scope, worktree);
713
+ return renderLookup(lookup, scope, cwd, worktree);
690
714
  }
691
715
 
692
- const resolved = resolveScope(scope, worktree);
716
+ const resolved = resolveScope(scope, cwd, worktree);
693
717
  const entries = [...resolved.entries].sort((a, b) =>
694
718
  a.path < b.path ? -1 : 1,
695
719
  );