fapony 0.5.0 → 0.6.1

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/src/init.ts CHANGED
@@ -58,22 +58,23 @@ const EVIDENCE_JSON = `{
58
58
  }
59
59
  `;
60
60
 
61
- // Rules snippet for the user's own agent-rules file. Printed, never written:
62
- // nothing writes log.<person>.jsonl on its own — an agent does, because the rules
63
- // file it already reads says to. That file is the user's (CLAUDE.md / AGENTS.md /
64
- // opencode.json instructions), so fapony hands over the text and stays out of it.
65
- // Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
66
- // and most of them never ran `fapony init` — it would tell them to run a command
67
- // that does not exist.
68
- const RULES_SNIPPET =
69
- () => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
61
+ // Rules snippet for the user's own agent-rules file. Nothing writes
62
+ // log.<person>.jsonl on its own — an agent does, because the rules file it already
63
+ // reads says to. That file is the user's, so init asks before writing it (rule 6c)
64
+ // and never writes it twice (RULES_MARKER). Not in SERVER_INSTRUCTIONS either: that
65
+ // reaches every MCP session of every user, and most of them never ran `fapony init`
66
+ // — it would tell them to run a command that does not exist.
67
+ const RULES_MARKER = "## Memory: .fapony/.memory";
68
+ const RULES_SNIPPET = () => `${RULES_MARKER}/log.<you>.jsonl (append-only)
70
69
 
71
70
  The log is this project's shared brain — it lives in git, so anyone who clones the
72
71
  repo gets every decision, bug and note with it. The filename comes from
73
72
  \`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
74
73
  .gitattributes keeps both sides when two people end up sharing a name anyway.
75
74
 
76
- Log as you work — do not wait to be asked. Nothing writes it for you:
75
+ Log as you work — do not wait to be asked. Nothing writes it for you. With the
76
+ fapony MCP server connected, call mem_add / mem_find / mem_close directly;
77
+ otherwise the CLI:
77
78
 
78
79
  fapony mem kickoff <plan.md> # start a session with this
79
80
  fapony mem add decision "what was locked, and why" --files src/x.ts
@@ -82,6 +83,14 @@ Log as you work — do not wait to be asked. Nothing writes it for you:
82
83
  fapony mem close <id> "fixed in <sha>"
83
84
  fapony mem find "<text>"
84
85
 
86
+ What goes in — would someone cloning this repo tomorrow need it, and can they not
87
+ find it anywhere else?
88
+ - bug — something broken, even when found mid-task on something else: add it now,
89
+ not at the end. Once fixed, close it — only close closes a bug.
90
+ - decision — something agreed or locked that git and the plan do not say, with why.
91
+ - note — state the next session needs (where a chunk stopped, what is half-done).
92
+ Before ending a turn that committed work: at least one row about it.
93
+
85
94
  Write each entry standalone — it is read months later with no chat to refer to.
86
95
  --files is required: rows that name no file cannot be recalled when that file is
87
96
  touched later (add refuses without it).
@@ -188,64 +197,117 @@ export function initProject(targetPath: string, config?: Config): void {
188
197
  );
189
198
  console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
190
199
  console.log(
191
- `\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
200
+ `\nThe memory log only fills up if the rules your agent already reads tell it\nto write — these go into CLAUDE.md / AGENTS.md next:\n`,
192
201
  );
193
202
  console.log(RULES_SNIPPET());
194
203
  }
195
204
 
196
205
  const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
197
206
 
198
- export async function cmdInit(args: string[]): Promise<void> {
199
- const targetPath = args[0];
200
- if (!targetPath) {
201
- console.error("usage: fapony init <path>");
202
- process.exit(1);
207
+ /** Which rules files init would touch: existing ones lacking the rules, or create. */
208
+ export function rulesTargets(targetPath: string): {
209
+ create: boolean;
210
+ append: string[];
211
+ } {
212
+ const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter(
213
+ existsSync,
214
+ );
215
+ const has = (f: string) => readFileSync(f, "utf-8").includes(RULES_MARKER);
216
+ const agents = join(targetPath, "AGENTS.md");
217
+ // a CLAUDE.md that imports a rules-carrying AGENTS.md already has them
218
+ const covered = (f: string) =>
219
+ has(f) ||
220
+ (/^@AGENTS\.md\s*$/m.test(readFileSync(f, "utf-8")) &&
221
+ existsSync(agents) &&
222
+ has(agents));
223
+ return {
224
+ create: found.length === 0,
225
+ append: found.filter((f) => !covered(f)),
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Write the memory rules into the agent-rules files. No file yet = AGENTS.md
231
+ * (read by OpenCode/Codex/Cursor) + a CLAUDE.md that imports it, so the rules
232
+ * exist once. Files that already carry the rules are left alone.
233
+ */
234
+ export function writeRules(targetPath: string): void {
235
+ const { create, append } = rulesTargets(targetPath);
236
+ if (create) {
237
+ writeFileSync(join(targetPath, "AGENTS.md"), `${RULES_SNIPPET()}\n`);
238
+ writeFileSync(join(targetPath, "CLAUDE.md"), "@AGENTS.md\n");
239
+ console.log(" + AGENTS.md (memory rules) · CLAUDE.md → @AGENTS.md");
240
+ return;
203
241
  }
204
- try {
205
- initProject(targetPath);
206
- } catch (e) {
207
- console.error((e as Error).message);
208
- process.exit(1);
242
+ for (const f of append) {
243
+ const body = readFileSync(f, "utf-8");
244
+ appendFileSync(
245
+ f,
246
+ `${body.endsWith("\n") ? "\n" : "\n\n"}${RULES_SNIPPET()}\n`,
247
+ );
248
+ console.log(` appended memory rules to ${relative(targetPath, f)}`);
209
249
  }
250
+ }
210
251
 
211
- // --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
212
- // eslint no-restricted-* rows carry their checker; the wrapper detector adds
213
- // live-migration candidates. Nothing derivable = empty file, never an error.
214
- const seed = await seedConventionsFile(targetPath);
215
- if (seed.kept) {
216
- console.log(
217
- ` ${relative(targetPath, seed.file)} — already exists, left untouched`,
218
- );
219
- } else {
220
- console.log(
221
- ` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
222
- );
223
- console.log(
224
- ` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
252
+ export async function cmdInit(args: string[]): Promise<void> {
253
+ const rulesOnly = args.includes("--rules");
254
+ const yes = args.includes("--yes");
255
+ const targetPath = args.find((a) => !a.startsWith("--"));
256
+ if (!targetPath) {
257
+ console.error("usage: fapony init <path> [--rules] [--yes]");
258
+ console.error(
259
+ " --rules only write the memory rules into CLAUDE.md / AGENTS.md (repo already set up)",
225
260
  );
261
+ console.error(" --yes write them without asking");
262
+ process.exit(1);
226
263
  }
227
- for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
264
+ if (!rulesOnly) {
265
+ try {
266
+ initProject(targetPath);
267
+ } catch (e) {
268
+ console.error((e as Error).message);
269
+ process.exit(1);
270
+ }
228
271
 
229
- const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter(
230
- existsSync,
231
- );
232
- if (found.length === 0) return;
272
+ // --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
273
+ // eslint no-restricted-* rows carry their checker; the wrapper detector adds
274
+ // live-migration candidates. Nothing derivable = empty file, never an error.
275
+ const seed = await seedConventionsFile(targetPath);
276
+ if (seed.kept) {
277
+ console.log(
278
+ ` ${relative(targetPath, seed.file)} — already exists, left untouched`,
279
+ );
280
+ } else {
281
+ console.log(
282
+ ` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
283
+ );
284
+ console.log(
285
+ ` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
286
+ );
287
+ }
288
+ for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
289
+ }
233
290
 
234
- const rl = createInterface({ input: process.stdin, output: process.stdout });
235
- const answer = await new Promise<string>((resolve) => {
236
- rl.question(
237
- `\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N] `,
238
- (a) => {
291
+ const { create, append } = rulesTargets(targetPath);
292
+ if (!create && append.length === 0) {
293
+ console.log(" memory rules already in the agent-rules file");
294
+ return;
295
+ }
296
+ if (!yes) {
297
+ const what = create
298
+ ? "Create AGENTS.md with the memory-logging rules (+ CLAUDE.md → @AGENTS.md)"
299
+ : `Append the memory-logging rules to ${append.map((f) => relative(targetPath, f)).join(" and ")}`;
300
+ const rl = createInterface({
301
+ input: process.stdin,
302
+ output: process.stdout,
303
+ });
304
+ const answer = await new Promise<string>((resolve) => {
305
+ rl.question(`\n${what}? [y/N] `, (a) => {
239
306
  rl.close();
240
307
  resolve(a.trim());
241
- },
242
- );
243
- });
244
- if (!isAffirmative(answer)) return;
245
-
246
- const snippet = `\n\n${RULES_SNIPPET()}\n`;
247
- for (const f of found) {
248
- appendFileSync(f, snippet);
249
- console.log(` appended to ${relative(targetPath, f)}`);
308
+ });
309
+ });
310
+ if (!isAffirmative(answer)) return;
250
311
  }
312
+ writeRules(targetPath);
251
313
  }
@@ -34,7 +34,9 @@ function isFaponyEntry(entry: unknown): boolean {
34
34
 
35
35
  function readJsonObject(path: string): Record<string, unknown> | null {
36
36
  try {
37
- const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
37
+ const raw = readFileSync(path, "utf-8").trim();
38
+ if (!raw) return {};
39
+ const parsed: unknown = JSON.parse(raw);
38
40
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
41
  return null;
40
42
  }
@@ -269,7 +269,7 @@ export const FaponyReadHint = async ({ directory }) => {
269
269
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
270
270
  }
271
271
  if (ctx.memLines.length > 0) {
272
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length });
272
+ recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
273
273
  }
274
274
  if (ctx.openBugIds.length > 0) {
275
275
  recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
@@ -413,7 +413,7 @@ export const FaponyEditHint = async ({ directory }) => {
413
413
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
414
414
  }
415
415
  if (ctx?.memLines?.length) {
416
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
416
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
417
417
  }
418
418
  if (ctx?.openBugIds?.length) {
419
419
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
package/src/map.ts CHANGED
@@ -167,10 +167,230 @@ function braceNames(
167
167
 
168
168
  const VAR_DECL_RE = /^export\s+(?:const|let|var)\b/;
169
169
 
170
+ // --- Python exports (no parser: Bun.Transpiler can't read .py, and shelling
171
+ // out to `python -c "import ast"` was cut — a subprocess per file blows the
172
+ // measured budgets (buildGraph ~50ms/150 files, review-seed 0.31–0.51s
173
+ // uncached) and breaks the Bun-only constraint. Line-based, top level only.)
174
+
175
+ const PY_DEF_RE = /^(?:async\s+)?def\s+([A-Za-z_]\w*)/;
176
+ const PY_CLASS_RE = /^class\s+([A-Za-z_]\w*)/;
177
+ // `x = …` and `x: T = …` — `=(?!=)` keeps `==`/`!=`/`>=` comparisons out.
178
+ const PY_ASSIGN_RE = /^([A-Za-z_]\w*)\s*(?::\s*[^=;#]+?)?=(?!=)/;
179
+ const PY_FROM_RE = /^from\s+(\S+)\s+import\s+(.+)$/;
180
+ const PY_ALL_RE = /^__all__\s*=/;
181
+
182
+ // Walk one line tracking quote state; returns the code before a `#` comment.
183
+ // Triple quotes are handled for the single-line case (`x = """a#b"""`) —
184
+ // multi-line strings are skipped by the block tracker below, not here.
185
+ function stripPyComment(line: string): string {
186
+ let quote: string | null = null;
187
+ for (let i = 0; i < line.length; i++) {
188
+ const c = line[i];
189
+ if (quote) {
190
+ if (c === "\\") {
191
+ i++;
192
+ continue;
193
+ }
194
+ if (line.startsWith(quote, i)) {
195
+ i += quote.length - 1;
196
+ quote = null;
197
+ }
198
+ continue;
199
+ }
200
+ if (c === "#") return line.slice(0, i);
201
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
202
+ quote = line.slice(i, i + 3);
203
+ i += 2;
204
+ } else if (c === '"' || c === "'") {
205
+ quote = c;
206
+ }
207
+ }
208
+ return line;
209
+ }
210
+
211
+ // Blank out triple-quoted string blocks, preserving the line count, so a
212
+ // line-based scan never reads code (`def`, `from .x import y`) out of a
213
+ // docstring or a multi-line string. Code before an opening `"""` on the same
214
+ // line is kept; the block itself and its closing line become empty. Single
215
+ // source of truth for both the export scan (below) and analyze's import scan.
216
+ // First triple quote that is NOT inside a single/double-quoted string on this
217
+ // line — so `x = 'has """ inside'` never opens a block. `stripPyComment` walks
218
+ // the same quote state; this reports where a block actually starts.
219
+ function unquotedTriple(code: string): { at: number; q: string } | null {
220
+ let quote: string | null = null;
221
+ for (let i = 0; i < code.length; i++) {
222
+ const c = code[i];
223
+ if (quote) {
224
+ if (c === "\\") {
225
+ i++;
226
+ continue;
227
+ }
228
+ if (code.startsWith(quote, i)) {
229
+ i += quote.length - 1;
230
+ quote = null;
231
+ }
232
+ continue;
233
+ }
234
+ if (code.startsWith('"""', i) || code.startsWith("'''", i))
235
+ return { at: i, q: code.slice(i, i + 3) };
236
+ if (c === '"' || c === "'") quote = c;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ export function maskPyBlocks(source: string): string {
242
+ const out: string[] = [];
243
+ let block: string | null = null;
244
+ for (const raw of source.split("\n")) {
245
+ if (block) {
246
+ const end = raw.indexOf(block);
247
+ if (end < 0) {
248
+ out.push("");
249
+ continue;
250
+ }
251
+ block = null;
252
+ out.push(raw.slice(end + 3));
253
+ continue;
254
+ }
255
+ const code = stripPyComment(raw);
256
+ const triple = unquotedTriple(code);
257
+ if (triple) {
258
+ const { at, q } = triple;
259
+ if (code.indexOf(q, at + 3) < 0) {
260
+ block = q;
261
+ out.push(code.slice(0, at));
262
+ continue;
263
+ }
264
+ }
265
+ out.push(raw);
266
+ }
267
+ return out.join("\n");
268
+ }
269
+
270
+ // String literals inside an `__all__ = [...]` (or `(...)`) assignment,
271
+ // possibly spanning lines. Anything dynamic (`append`, `+=`, a variable)
272
+ // yields nothing — the caller falls back to every top-level name.
273
+ function pyAllNames(text: string): string[] {
274
+ const open =
275
+ text.indexOf("[") >= 0 ? "[" : text.indexOf("(") >= 0 ? "(" : null;
276
+ if (!open) return [];
277
+ const close = open === "[" ? "]" : ")";
278
+ const body = text.slice(text.indexOf(open) + 1);
279
+ if (!body.includes(close)) return [];
280
+ const out: string[] = [];
281
+ for (const m of body.matchAll(/["']([A-Za-z_]\w*)["']/g)) out.push(m[1]);
282
+ return out;
283
+ }
284
+
285
+ function pyFromNames(rest: string): string[] {
286
+ const clean = rest.replace(/[()]/g, " ");
287
+ const out: string[] = [];
288
+ for (let part of clean.split(",")) {
289
+ part = part.trim().split("#")[0].trim();
290
+ if (!part || part === "*") continue;
291
+ const as = part.split(/\s+as\s+/);
292
+ const name = as[as.length - 1].trim();
293
+ if (/^[A-Za-z_]\w*$/.test(name)) out.push(name);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ export function extractPythonExports(source: string): ExportScan {
299
+ // Blank docstrings/strings first, preserving line numbers — a `def` inside
300
+ // a docstring is sample text, the same blind spot the TS path closes with
301
+ // the parse gate.
302
+ const lines = maskPyBlocks(source).split("\n");
303
+ const found = new Map<string, { line: number; kind: ExportKind }>();
304
+ let allNames: string[] = [];
305
+ let allLine = 0;
306
+
307
+ const remember = (name: string, line: number, kind: ExportKind): void => {
308
+ if (!found.has(name)) found.set(name, { line, kind });
309
+ };
310
+
311
+ for (let i = 0; i < lines.length; i++) {
312
+ const raw = lines[i];
313
+ const code = stripPyComment(raw).trimEnd();
314
+ if (code === "") continue;
315
+ if (/^\s/.test(raw)) continue; // indented — not top level
316
+ const t = code.trim();
317
+ let m: RegExpMatchArray | null;
318
+ if ((m = t.match(PY_DEF_RE))) {
319
+ remember(m[1], i + 1, "fn");
320
+ continue;
321
+ }
322
+ if ((m = t.match(PY_CLASS_RE))) {
323
+ remember(m[1], i + 1, "class");
324
+ continue;
325
+ }
326
+ if (PY_ALL_RE.test(t)) {
327
+ // `__all__` may span lines — join until the bracket closes (cap 20).
328
+ let joined = t;
329
+ let j = i;
330
+ while (!/[\])]/.test(joined) && j + 1 < lines.length && j - i < 20) {
331
+ j++;
332
+ joined += ` ${stripPyComment(lines[j]).trim()}`;
333
+ }
334
+ allNames = pyAllNames(joined);
335
+ allLine = i + 1;
336
+ i = j;
337
+ continue;
338
+ }
339
+ if ((m = t.match(PY_FROM_RE))) {
340
+ // Parenthesized lists often span lines — join until they close (cap 20),
341
+ // the same shape as the TS brace-block join above.
342
+ let rest = m[2];
343
+ let j = i;
344
+ const unbalanced = (s: string): boolean =>
345
+ (s.match(/\(/g) ?? []).length > (s.match(/\)/g) ?? []).length;
346
+ while (unbalanced(rest) && j + 1 < lines.length && j - i < 20) {
347
+ j++;
348
+ rest += ` ${stripPyComment(lines[j]).trim()}`;
349
+ }
350
+ for (const name of pyFromNames(rest)) remember(name, i + 1, "re-export");
351
+ i = j;
352
+ continue;
353
+ }
354
+ if (/^import\s+/.test(t)) continue; // graph data, not an export
355
+ if ((m = t.match(PY_ASSIGN_RE))) {
356
+ if (m[1] === "__all__") continue;
357
+ remember(m[1], i + 1, "const");
358
+ }
359
+ }
360
+
361
+ if (allNames.length > 0) {
362
+ // `__all__` is authoritative: undocumented underscore names listed there
363
+ // are public, and anything not listed is not — including real defs.
364
+ const out: ExportSymbol[] = [];
365
+ const seen = new Set<string>();
366
+ for (const name of allNames) {
367
+ if (seen.has(name)) continue;
368
+ seen.add(name);
369
+ const f = found.get(name);
370
+ out.push(
371
+ f
372
+ ? { name, line: f.line, kind: f.kind }
373
+ : { name, line: allLine, kind: "re-export" },
374
+ );
375
+ }
376
+ return { symbols: out, error: null };
377
+ }
378
+ // No `__all__`: underscore-prefixed names are private by convention.
379
+ return {
380
+ symbols: [...found]
381
+ .filter(([name]) => !name.startsWith("_"))
382
+ .map(([name, f]) => ({ name, line: f.line, kind: f.kind })),
383
+ error: null,
384
+ };
385
+ }
386
+
170
387
  export function extractExports(
171
388
  source: string,
172
389
  scanner?: ExportScanner,
390
+ filename?: string,
173
391
  ): ExportScan {
392
+ if (filename?.endsWith(".py") || filename?.endsWith(".pyi"))
393
+ return extractPythonExports(source);
174
394
  const s = scanner ?? getDefaultScanner();
175
395
  const scanned = scanSource(source, s);
176
396
  if (scanned.error) return { symbols: [], error: scanned.error };
@@ -28,10 +28,13 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
28
28
  // Distinct keys on open work rows, sorted — the important-index ingredient
29
29
  // (PLAN-mem-keys chunk 3). A key is open while ≥1 keyed row survives the
30
30
  // tombstone set; computed live like every other selector, never persisted.
31
+ // Decisions are skipped: nobody closes them, so a keyed decision would sit in
32
+ // the index forever as a fake "open problem".
31
33
  export const openKeys = (all: LogRow[]): { key: string; open: number }[] => {
32
34
  const counts = new Map<string, number>();
33
35
  for (const r of openRows(all)) {
34
- if (r.key) counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
36
+ if (r.key && r.kind !== "decision")
37
+ counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
35
38
  }
36
39
  return [...counts.entries()]
37
40
  .map(([key, open]) => ({ key, open }))
@@ -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,
@@ -221,7 +225,7 @@ function renderExistingInScope(
221
225
  } catch {
222
226
  continue;
223
227
  }
224
- const scan = extractExports(source);
228
+ const scan = extractExports(source, undefined, rel);
225
229
  if (scan.error || scan.symbols.length === 0) continue;
226
230
  // Re-export-only files scan as one `*` per line — dedupe to a single `*`.
227
231
  const names = [
@@ -347,7 +351,7 @@ function fileLines(absFile: string): string[] {
347
351
  } catch {
348
352
  return ["_(unreadable)_"];
349
353
  }
350
- const scan = extractExports(source);
354
+ const scan = extractExports(source, undefined, absFile);
351
355
  if (scan.error) return [`⚠ ${scan.error} — symbols not extractable`];
352
356
  if (scan.symbols.length === 0) return ["_(no exports)_"];
353
357
  let srcLines: string[] = [];
@@ -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";
@@ -707,7 +707,7 @@ function renderLookup(
707
707
  } catch {
708
708
  continue;
709
709
  }
710
- const scan = extractExports(source);
710
+ const scan = extractExports(source, undefined, path);
711
711
  if (scan.error) continue;
712
712
  for (const sym of scan.symbols) {
713
713
  if (!flags.body.includes(sym.name)) continue;
@@ -902,7 +902,7 @@ export function renderSeed(args: string[], cwd: string): string {
902
902
  } catch {
903
903
  continue;
904
904
  }
905
- const scan = extractExports(source);
905
+ const scan = extractExports(source, undefined, e.path);
906
906
  if (scan.error) {
907
907
  sigLines.push(` ${e.path} — ⚠ ${scan.error}`);
908
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.**