fapony 0.7.0 → 0.7.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.
Files changed (64) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -8
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +2 -11
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -211
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -400
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -258
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -394
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -792
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -284
  59. package/src/mem/engine.ts +0 -329
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/key-registry.ts +0 -116
  62. package/src/mem/render.ts +0 -66
  63. package/src/mem/selectors.ts +0 -227
  64. package/src/mem/store.ts +0 -356
@@ -1,4 +1,5 @@
1
- // commands/plan.ts — plan-sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
1
+ // src/plan/sweep.ts — `fapony plan sweep` + `fapony plan check`.
2
+ // sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
2
3
  // rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
3
4
  // no arg = report only (safe, shows every kickoff/stale run)
4
5
  // <file.md> = check a single file, is it ready to move
@@ -11,22 +12,26 @@ import {
11
12
  mkdirSync,
12
13
  readdirSync,
13
14
  readFileSync,
15
+ realpathSync,
16
+ renameSync,
14
17
  writeFileSync,
15
18
  } from "node:fs";
16
19
  import { basename, dirname, join, relative, resolve } from "node:path";
17
20
 
18
- import { openRows } from "../selectors.js";
19
- import {
20
- doneDir,
21
- memCmd,
22
- nextId,
23
- planBase,
24
- planDir,
25
- put,
26
- rel,
27
- root,
28
- rows,
29
- } from "../store.js";
21
+ import { type MemRow, readFaelLog } from "../fael.js";
22
+ import { doneDir, planBase, planDir, rel, root } from "./store.js";
23
+
24
+ // Open fael rows about a plan — matched by basename on files[] or spec, so a
25
+ // row still counts after the plan moves plan/ → done/. One fael call per run.
26
+ let openCache: { root: string; rows: MemRow[] } | null = null;
27
+ export const openRowsFor = (planPath: string): MemRow[] => {
28
+ if (openCache?.root !== root)
29
+ openCache = { root, rows: readFaelLog(root, undefined, true).rows };
30
+ const name = basename(planPath);
31
+ return openCache.rows.filter((r) =>
32
+ [...(r.files ?? []), r.spec ?? ""].some((f) => basename(f) === name),
33
+ );
34
+ };
30
35
 
31
36
  const SHIPPED = /^>\s*✅/m;
32
37
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -284,7 +289,7 @@ export const hasShippedHeader = (file: string): boolean => {
284
289
  return !HELD.test(FRONT.exec(head)?.[1] ?? "");
285
290
  };
286
291
 
287
- export const planSweepCmd = `${memCmd} plan-sweep`;
292
+ export const planSweepCmd = "fapony plan sweep";
288
293
 
289
294
  const mdFiles = (dir: string): string[] =>
290
295
  existsSync(dir)
@@ -411,16 +416,15 @@ export const cmdPlanSweep = (a: string[]) => {
411
416
  const apply = a.includes("--apply");
412
417
 
413
418
  if (!target) {
414
- const all = rows();
415
419
  if (candidates.length) {
416
420
  console.log(
417
421
  `# plan-sweep — ${candidates.length} file(s) marked shipped but not archived\n`,
418
422
  );
419
423
  for (const name of candidates) {
420
424
  const spec = `${rel(dir)}/${name}`;
421
- const openN = openRows(all).filter((r) => r.spec === spec).length;
425
+ const openN = openRowsFor(spec).filter((r) => r.kind === "bug").length;
422
426
  const warn = openN
423
- ? ` ⚠ ${openN} open row(s) (next/bug/hold/decision/note) — check before moving`
427
+ ? ` ⚠ ${openN} open issue(s) (fael) — close before moving`
424
428
  : "";
425
429
  console.log(`- ${rel(dir)}/${name}${warn}`);
426
430
  }
@@ -443,7 +447,7 @@ export const cmdPlanSweep = (a: string[]) => {
443
447
  for (const f of blocked) {
444
448
  const { checked, unchecked } = countFirstSection(f);
445
449
  const spec = rel(f);
446
- const openN = openRows(all).filter((r) => r.spec === spec).length;
450
+ const openN = openRowsFor(spec).length;
447
451
  const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
448
452
  console.log(
449
453
  `- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
@@ -467,9 +471,11 @@ export const cmdPlanSweep = (a: string[]) => {
467
471
  }
468
472
  const name = basename(src);
469
473
  const srcDir = dirname(src);
470
- const shipped = hasShippedHeader(src);
474
+ const fm = parsePlanFrontmatter(src);
475
+ // superseded = closed without shipping; done/ is where closed plans live
476
+ const superseded = fm.status === "superseded";
477
+ const shipped = hasShippedHeader(src) || superseded;
471
478
  if (!apply) {
472
- const fm = parsePlanFrontmatter(src);
473
479
  if (fm.status === "blocked") {
474
480
  console.log(
475
481
  `${target}: status:blocked (blocked_by: ${fm.blockedByRaw ?? "?"}) — not a move candidate, stays in plan/`,
@@ -477,9 +483,11 @@ export const cmdPlanSweep = (a: string[]) => {
477
483
  return;
478
484
  }
479
485
  console.log(
480
- shipped
481
- ? `${target}: has a ✅ shipped header — ready to move (add --apply)`
482
- : `${target}: no ✅ shipped header at the top — check the whole file is actually done`,
486
+ superseded
487
+ ? `${target}: status:superseded — ready to move (add --apply)`
488
+ : shipped
489
+ ? `${target}: has a ✅ shipped header — ready to move (add --apply)`
490
+ : `${target}: no ✅ shipped header at the top — check the whole file is actually done`,
483
491
  );
484
492
  return;
485
493
  }
@@ -488,15 +496,17 @@ export const cmdPlanSweep = (a: string[]) => {
488
496
  // but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
489
497
  if (!shipped && !process.env.MEM_FORCE) {
490
498
  console.error(
491
- `${name}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
499
+ `${name}: no ✅ shipped header and not status:superseded — refusing to move (MEM_FORCE=1 to override)`,
492
500
  );
493
501
  process.exit(1);
494
502
  }
495
- const openSpec = rel(src);
496
- const openN = openRows(rows()).filter((r) => r.spec === openSpec);
503
+ // Notes and decisions are the plan's history and travel with it (basename
504
+ // match survives the move); only an open issue (MemRow kind "bug") is
505
+ // unfinished work.
506
+ const openN = openRowsFor(rel(src)).filter((r) => r.kind === "bug");
497
507
  if (openN.length && !process.env.MEM_FORCE) {
498
508
  console.error(
499
- `${name}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
509
+ `${name}: still has ${openN.length} open issue(s) (fael) — close them first (MEM_FORCE=1 to override):\n` +
500
510
  openN.map((r) => ` [${r.id}] ${r.kind} ${r.text}`).join("\n"),
501
511
  );
502
512
  process.exit(1);
@@ -511,11 +521,22 @@ export const cmdPlanSweep = (a: string[]) => {
511
521
  // ponytail: a file just written this round may not be git add'ed yet — `git mv` fails silently (exit 128, no throw)
512
522
  // then the next code hits ENOENT reading a dst that does not exist — always stage first (no-op if already tracked)
513
523
  mkdirSync(doneDir, { recursive: true });
514
- Bun.spawnSync(["git", "add", src]);
515
- const mv = Bun.spawnSync(["git", "mv", src, dst]);
516
- if (mv.exitCode !== 0) {
517
- console.error(`git mv failed (${mv.stderr.toString().trim()}) — not moved`);
518
- process.exit(1);
524
+ // a repo that gitignores .fapony/ (public repo, private plans) has nothing
525
+ // for git to move — `git add` refuses an ignored path, so rename instead
526
+ if (gitOk(["check-ignore", "-q", src], root)) {
527
+ renameSync(src, dst);
528
+ console.log(
529
+ `${rel(src)} is not tracked by git — moved with a plain rename`,
530
+ );
531
+ } else {
532
+ Bun.spawnSync(["git", "add", src]);
533
+ const mv = Bun.spawnSync(["git", "mv", src, dst]);
534
+ if (mv.exitCode !== 0) {
535
+ console.error(
536
+ `git mv failed (${mv.stderr.toString().trim()}) — not moved`,
537
+ );
538
+ process.exit(1);
539
+ }
519
540
  }
520
541
 
521
542
  // own links always need re-relativizing — the file changed directory even in
@@ -543,18 +564,6 @@ export const cmdPlanSweep = (a: string[]) => {
543
564
  `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(planBase)}/**)`,
544
565
  );
545
566
 
546
- // log decision — record ship event (reuse existing kind, no new schema)
547
- const doneSpec = `${rel(dst)}`;
548
- put({
549
- id: nextId(rows()),
550
- kind: "decision",
551
- text: `${name} shipped → ${rel(dst)}`,
552
- spec: doneSpec,
553
- });
554
- // ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
555
- // immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
556
- put({ kind: "synced", spec: doneSpec });
557
-
558
567
  // the ship may unblock waiting plans — the dep graph lives in frontmatter,
559
568
  // so say which active plans name this file as their blocker (or were named
560
569
  // in this file's own blocks:). Detect-only: the dependent keeps
@@ -736,6 +745,18 @@ export const cmdPlanCheck = (a: string[]) => {
736
745
  let m: RegExpExecArray | null;
737
746
  while ((m = linkRe.exec(src)) !== null) {
738
747
  const target = m[1];
748
+ if (target?.startsWith("file://")) {
749
+ // works on the machine that wrote it, nowhere else — name the
750
+ // relative path instead of calling an existing file "broken"
751
+ const [abs] = decodeURI(target.slice("file://".length)).split("#");
752
+ const at = `${f.replace(`${dir}/`, "")}:${src.slice(0, m.index).split("\n").length}`;
753
+ issues.push(
754
+ abs && existsSync(abs)
755
+ ? `${at} — absolute file:// link → ${target} (works on this machine only)\n fix: use ${relative(realpathSync(dirname(f)), realpathSync(abs))}`
756
+ : `${at} — broken link → ${target}\n fix: correct the path or create the file it points at`,
757
+ );
758
+ continue;
759
+ }
739
760
  if (!target || /^(https?:|mailto:|\/)/.test(target)) continue;
740
761
  const [pathPart] = target.split("#");
741
762
  if (!pathPart) continue;
@@ -811,14 +832,13 @@ export const cmdPlanCheck = (a: string[]) => {
811
832
  (f) => parsePlanFrontmatter(f).status === "blocked",
812
833
  );
813
834
  if (blocked.length) {
814
- const all = rows();
815
835
  console.log(
816
836
  `\nblocked plans (${blocked.length}) — waiting, not candidates:`,
817
837
  );
818
838
  for (const f of blocked) {
819
839
  const { checked, unchecked } = countFirstSection(f);
820
840
  const spec = rel(f);
821
- const openN = openRows(all).filter((r) => r.spec === spec).length;
841
+ const openN = openRowsFor(spec).length;
822
842
  const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
823
843
  console.log(
824
844
  `- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
@@ -47,9 +47,8 @@ import {
47
47
  planDir,
48
48
  specDir,
49
49
  } from "../core/config.js";
50
- import { MEM_TEXT_MAX } from "../core/mem-log.js";
50
+ import { readFaelLog, recentDecisions } from "../fael.js";
51
51
  import { extractExports } from "../map.js";
52
- import { readMemLog, readRecentMemDecisions } from "../memory.js";
53
52
  import { capLines, execGit, SIG_MAX } from "./primitives.js";
54
53
 
55
54
  // One chunk = one module's signatures — past ~40 lines a module is its own
@@ -63,6 +62,8 @@ const SCOPE_WARN_FILES = 300;
63
62
  // Shipped plans/specs that already touched this scope. Capped low on purpose:
64
63
  // this is a "go read that first" pointer, not a bibliography.
65
64
  const MAX_PRIOR_ART = 5;
65
+ // Mem-row text budget in the Known traps block.
66
+ const MEM_TEXT_MAX = 120;
66
67
  // Chunk 4 (PLAN-seed-and-surface): the PLAN names what is already in scope —
67
68
  // one line per scope file with its export names. SPEC-only seeds never gave
68
69
  // PLAN-only readers this pointer, so agents re-derived what export-lines.ts
@@ -241,7 +242,7 @@ function renderExistingInScope(
241
242
 
242
243
  function renderContextFapony(worktree: string): string {
243
244
  const lines: string[] = [];
244
- const decisions = readRecentMemDecisions(worktree, 3).slice(0, 3);
245
+ const decisions = recentDecisions(readFaelLog(worktree).rows, 3);
245
246
  lines.push(
246
247
  decisions.length > 0
247
248
  ? `- Decisions on record (mem): ${decisions
@@ -250,7 +251,7 @@ function renderContextFapony(worktree: string): string {
250
251
  `"${d.text.length > 140 ? `${d.text.slice(0, 139)}…` : d.text}"`,
251
252
  )
252
253
  .join(" · ")}`
253
- : "- Decisions on record (mem): _(none — no mem log or empty)_",
254
+ : "- Decisions on record (mem): _(none — no fael log or empty)_",
254
255
  );
255
256
  return lines.join("\n");
256
257
  }
@@ -305,7 +306,7 @@ One step = one chunk = one session: finish it, close it, **stop** — starting t
305
306
 
306
307
  1. _(agent fills in — each step must be verifiable)_
307
308
 
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
+ **Closing a step:** tick TL;DR with sha · \`git commit\` files only · \`fael add note "<what chunk N+1 must know>" --files <f1,f2>,${planRel}\` · next opens with \`fapony plan PLAN-${name}.md\` (unchecked chunks + those notes).
309
310
  - [ ] handoff: the mem note is the handoff — this box only opts the plan into the Stop-hook check
310
311
 
311
312
  ## 7. Examples
@@ -569,7 +570,7 @@ function specTemplate(
569
570
  //
570
571
  // The one place a seed is allowed to be opinionated: past pain about exactly
571
572
  // 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
+ // signal), newest first within a kind. Match mirrors fael find: files[] first;
573
574
  // the text/spec fallback runs ONLY for rows with no files[] at all — a row
574
575
  // that named files already spoke, its text may quote any path.
575
576
  //
@@ -601,8 +602,8 @@ export function renderKnownTraps(
601
602
  ): { lines: string[]; matched: number; lacked: number } {
602
603
  const empty = { lines: [], matched: 0, lacked: 0 };
603
604
  try {
604
- const { rows, filesFound } = readMemLog(worktree);
605
- if (filesFound === 0 || rows.length === 0) return empty;
605
+ const { rows } = readFaelLog(worktree);
606
+ if (rows.length === 0) return empty;
606
607
 
607
608
  const scopeFiles = new Set<string>();
608
609
  for (const r of roots)
@@ -637,7 +638,7 @@ export function renderKnownTraps(
637
638
  );
638
639
  const lacked = hits.filter((h) => h.viaText).length;
639
640
  const lines = [
640
- "## Known traps (fapony mem)",
641
+ "## Known traps (fael)",
641
642
  "",
642
643
  `- ${hits.length} relevant row(s) on this scope (${lacked} lacked files[]${lacked > 0 ? " — matched via text" : ""})`,
643
644
  ];
package/src/setup.ts CHANGED
@@ -62,7 +62,6 @@ export interface SetupDeps {
62
62
  export interface SetupAnswers {
63
63
  worktreeName: string;
64
64
  worktreePath: string;
65
- enableMemory: boolean;
66
65
  }
67
66
 
68
67
  /** Pure config builder — the config-write path of cmdSetup, minus prompting.
@@ -77,15 +76,6 @@ export function buildSetupConfig(a: SetupAnswers): Record<string, unknown> {
77
76
  memory: null,
78
77
  };
79
78
 
80
- if (a.enableMemory) {
81
- config.memory = {
82
- claim: ["fapony", "mem", "claim", "{id}"],
83
- close: ["fapony", "mem", "close", "{id}", "{msg}"],
84
- add: ["fapony", "mem", "add", "{kind}", "{text}"],
85
- kickoff: ["fapony", "mem", "kickoff"],
86
- };
87
- }
88
-
89
79
  return config;
90
80
  }
91
81
 
@@ -159,12 +149,6 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
159
149
  defaultName,
160
150
  );
161
151
 
162
- // --- memory ---
163
- console.log();
164
- const enableMemory = isAffirmative(
165
- await askFn("Enable project memory? (y/n)", "n"),
166
- );
167
-
168
152
  // --- write config ---
169
153
  // loadConfig() resolves to FAPONY_CONFIG or cwd/fapony.config.json, so a
170
154
  // cwd write is consistent — but warn when cwd isn't a fapony checkout,
@@ -179,7 +163,6 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
179
163
  const config = buildSetupConfig({
180
164
  worktreeName,
181
165
  worktreePath,
182
- enableMemory,
183
166
  });
184
167
 
185
168
  const configPath = join(process.cwd(), CONFIG_FILENAME);
@@ -224,12 +207,11 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
224
207
  ┌─────────────────────────────────────────┐
225
208
  │ Setup complete! Next steps: │
226
209
  │ │
227
- │ 1. Wire fapony into your MCP client: │
228
- │ fapony install --platform opencode │
229
- │ fapony install --platform claude │
210
+ │ 1. Wire fapony into your clients: │
211
+ │ fapony install │
230
212
  │ │
231
- │ 2. Ask your agent: │
232
- │ "Find past decisions with mem_find" │
213
+ │ 2. See where your plans stand: │
214
+ │ fapony plan │
233
215
  │ │
234
216
  │ 3. Read a run's report: │
235
217
  │ fapony report <run-id> │
package/src/update.ts CHANGED
@@ -155,7 +155,7 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
155
155
  if (isRepo !== "true") {
156
156
  console.error(`❌ ${ROOT} is not a git repo — cannot self-update.`);
157
157
  console.error(
158
- " Reinstall via: git clone https://github.com/kire21b/fapony.git",
158
+ " Reinstall via: git clone https://github.com/inonix-dev/fapony.git",
159
159
  );
160
160
  exitFn(1);
161
161
  }
package/templates/PLAN.md CHANGED
@@ -11,7 +11,7 @@ blocked_by: <plan or sentence> # required when status: blocked
11
11
  blocks: PLAN-<other>.md # plans that cannot start until this one lands (comma-separated)
12
12
  superseded_by: PLAN-<other>.md # required when status: superseded
13
13
  spec: SPEC-<feature>.md # if any
14
- priority: high # optional: high = appears first in kickoff · omit = normal
14
+ priority: high # optional: high = listed first by `fapony plan` · omit = normal
15
15
  ---
16
16
 
17
17
  # PLAN-<feature>.md — <short name>
@@ -1,60 +0,0 @@
1
- // src/adapters/hooks/bug-markers.ts — how fapony recognises "a bug was found".
2
- //
3
- // Split from stop.ts so the Stop hook (blocking, claude/cursor/codex) and the
4
- // OpenCode commit hint (advisory) share one definition. A bug that lives only
5
- // in a `note` row is not surfaced by anything, so the two signals are:
6
- //
7
- // 1. a structured commit-message token — `fix:` / `bugfix:` / `hotfix:`.
8
- // Universal by construction: only the type token is English, so a Thai
9
- // description on a conventional commit still reads as a bug
10
- // (`fix(debt): ...`). This is the language-independent signal, and it is
11
- // already how this repo commits (120/581 commits are fix-family).
12
- // 2. free-text announcement phrases — inherently per-language. A substring
13
- // list cannot be universal; keep it small and open instead. Add your
14
- // language here — do NOT invent a new row kind for it (open/closed is
15
- // derived from close rows already; a kind would just drift).
16
- //
17
- // Deliberately NOT matched: symptom words ("broken", "dies silently"). They
18
- // appear in every bug report and would fire on any turn that reads one — the
19
- // Stop hook blocks once per session on a match, so a false positive is costly.
20
-
21
- /** Free-text announcement phrases ("I found a bug"), never symptom words.
22
- * `g` flag is required — hasBugMarker walks every match (matchAll). */
23
- export const BUG_MARKERS: RegExp[] = [
24
- /(?:เจอ|พบ)(?:ว่า)?(?:เป็น)?บั๊ก/g, // พบบั๊ก · พบว่าเป็นบั๊กจริง
25
- /บั๊กที่(?:เจอ|พบ)/g,
26
- /\bfound (?:a |the )?(?:real |actual )?bug\b/gi,
27
- /\b(?:this|that|it)(?:'s| is) a (?:real )?bug\b/gi,
28
- /\b(?:bug confirmed|confirmed (?:a |real )?bug)\b/gi,
29
- /\bbug\b\s*(?:\([^)\n]{0,80}\))?\s*:/gi, // **Bug (cause…):**
30
- ];
31
-
32
- // Negation / hypothetical right before a match ("ไม่พบบั๊ก", "จะเจอบั๊ก",
33
- // "not a bug"). Bare "เป็นบั๊ก" is deliberately not a marker: "อาจเป็นบั๊ก" is
34
- // everywhere and a false fire costs a Stop-hook block.
35
- const NEGATED = /(?:ไม่|จะ|ถ้า|อาจ|\bnot\s|\bno\s|\bif\s)\s*$/i;
36
-
37
- /**
38
- * The matched marker phrase, or null. Returns the matched text so the caller
39
- * can quote it back to the agent (stop.ts does, in its block reason). Every
40
- * match is checked, so "ไม่พบบั๊กใหม่ แต่เจอบั๊กที่ X" still fires on the second.
41
- */
42
- export function hasBugMarker(text: string): string | null {
43
- for (const re of BUG_MARKERS) {
44
- for (const m of text.matchAll(re)) {
45
- const before = text.slice(Math.max(0, m.index - 15), m.index);
46
- if (!NEGATED.test(before)) return m[0];
47
- }
48
- }
49
- return null;
50
- }
51
-
52
- // Conventional-commit bug types, anchored to the subject prefix — arbitrary
53
- // prose containing "fix" ("pre-fix the cache") must not match. Language-free:
54
- // the type token stays English even when the description is not.
55
- const BUGFIX_COMMIT = /^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:/;
56
-
57
- /** True when a commit subject declares a fix (the universal bug token). */
58
- export function isBugfixCommit(subject: string): boolean {
59
- return BUGFIX_COMMIT.test(subject.trim());
60
- }
@@ -1,211 +0,0 @@
1
- // src/adapters/hooks/context-data.ts — shared debt + mem context reader
2
- //
3
- // Split from src/hook.ts (PLAN-lib-layer chunk 3). Used by both read-hint and
4
- // edit-hint adapters to attach debt/mem lines when a file is open.
5
-
6
- import {
7
- appendFileSync,
8
- existsSync,
9
- mkdirSync,
10
- readFileSync,
11
- realpathSync,
12
- } from "node:fs";
13
- import { basename, dirname, join, relative } from "node:path";
14
- import { collectSourceFiles, SCAN_EXTS } from "../../analyze/index.js";
15
- import { readTrackDir, sessionKey } from "../../core/hook-helpers.js";
16
- import { clipMemText, readMemLog } from "../../core/mem-log.js";
17
- import { debtForFile, resolveDebtScope } from "../../debt/index.js";
18
- import { movedTargets } from "../../mem/selectors.js";
19
-
20
- const DEBT_HINT_MAX = 3;
21
- const MEM_HINT_MAX = 2;
22
-
23
- export interface ContextLineData {
24
- worktree: string;
25
- debtIds: string[];
26
- debtLines: string[];
27
- memLines: string[];
28
- /** Ids of open bugs actually emitted as OPEN BUG lines above (for the fire log). */
29
- openBugIds: string[];
30
- /** Ids of every mem row emitted in memLines (open bugs first) — joined to authors offline. */
31
- memIds: string[];
32
- }
33
-
34
- /** Mem row ids already shown this session — a row repeats on every read/edit otherwise. */
35
- function shownPath(session: string): string {
36
- return join(readTrackDir(), `${sessionKey(session)}.mem-shown`);
37
- }
38
-
39
- function readShown(session: string): Set<string> {
40
- try {
41
- const p = shownPath(session);
42
- if (!existsSync(p)) return new Set();
43
- return new Set(readFileSync(p, "utf-8").split("\n").filter(Boolean));
44
- } catch {
45
- return new Set();
46
- }
47
- }
48
-
49
- function markShown(session: string, ids: string[]): void {
50
- if (ids.length === 0) return;
51
- try {
52
- mkdirSync(readTrackDir(), { recursive: true });
53
- appendFileSync(shownPath(session), `${ids.join("\n")}\n`, "utf-8");
54
- } catch {
55
- // a hint must never fail the tool call
56
- }
57
- }
58
-
59
- /**
60
- * Structured data behind readContextLines — used by cmdHookReadHint for logging.
61
- * With a session, a non-bug mem row shows once per session (open bugs always
62
- * show: they are exact files[] matches and the point is to nag until closed).
63
- */
64
- export function readContextData(
65
- filePath: unknown,
66
- cwd: string,
67
- session?: unknown,
68
- ): ContextLineData | null {
69
- try {
70
- if (typeof filePath !== "string" || filePath === "") return null;
71
- const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
72
- cwd,
73
- stdout: "pipe",
74
- stderr: "pipe",
75
- });
76
- if (git.exitCode !== 0) return null;
77
- const worktree = realpathSync(git.stdout.toString().trim());
78
- const abs = realpathSync(
79
- filePath.startsWith("/") ? filePath : join(worktree, filePath),
80
- );
81
- const rel = relative(worktree, abs).split("\\").join("/");
82
- if (rel.startsWith("..") || rel === "") return null;
83
-
84
- const debtIds: string[] = [];
85
- const debtLines: string[] = [];
86
- const memLines: string[] = [];
87
- const openBugIds: string[] = [];
88
- const memIds: string[] = [];
89
-
90
- // convention debt — source files only, fresh from the repo. The scope
91
- // pairs the git root (repo-relative `where`) with the nearest
92
- // conventions file — anchoring the load at the root goes silent in a
93
- // monorepo with app-scoped conventions (bug mucvfaxk).
94
- const dot = rel.lastIndexOf(".");
95
- if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
96
- const scope = resolveDebtScope(dirname(abs));
97
- for (const c of debtForFile(scope.scanRoot, abs, scope.loaded).slice(
98
- 0,
99
- DEBT_HINT_MAX,
100
- )) {
101
- debtIds.push(c.id);
102
- debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
103
- }
104
- }
105
-
106
- // mem rows that are about this file — resolve the log from the file's own
107
- // directory, not the repo root. In a monorepo the log is app-scoped, so
108
- // anchoring at the root sees only an out-of-scope candidate and goes silent
109
- // even though the file being touched sits right under its log (bug muc9q47r).
110
- const mem = readMemLog(dirname(abs));
111
- if (mem.rows.length > 0) {
112
- // PLAN-mem-core chunk 5 — follow moves: a row naming old/a.ts still
113
- // shows when the file now lives at new/a.ts, as long as no other a.ts
114
- // exists on disk. Missing = stat-miss on the worktree-joined path; the
115
- // tree walk runs only when some row actually names a missing file.
116
- const missingOld = new Set<string>();
117
- for (const r of mem.rows)
118
- for (const f of r.files ?? [])
119
- if (f !== rel && !existsSync(join(worktree, f))) missingOld.add(f);
120
- const moved =
121
- missingOld.size > 0
122
- ? movedTargets([...missingOld], collectSourceFiles(worktree))
123
- : null;
124
- const filesOf = (r: (typeof mem.rows)[number]): string[] =>
125
- (r.files ?? []).map((f) => moved?.get(f) ?? f);
126
- const movedFrom = (r: (typeof mem.rows)[number]): string => {
127
- const from = (r.files ?? []).find(
128
- (f) => f !== rel && moved?.get(f) === rel,
129
- );
130
- return from ? ` (moved from ${from})` : "";
131
- };
132
- // Open bugs first (PLAN-active-pain chunk 3): a kind:"bug" row whose id
133
- // is in no close row's ref — the same tombstone shape as openRows in
134
- // src/mem/selectors.ts, never a regex on text. Exact files[] match only:
135
- // a bug row with no files[] stays silent rather than guessed at.
136
- const closed = new Set(
137
- mem.rows.filter((r) => r.kind === "close").map((r) => r.ref),
138
- );
139
- const openLines: string[] = [];
140
- for (const r of mem.rows) {
141
- if (openLines.length >= MEM_HINT_MAX) break;
142
- if (r.kind !== "bug" || !r.id || closed.has(r.id)) continue;
143
- if (!filesOf(r).includes(rel)) continue;
144
- openLines.push(
145
- `fapony mem: OPEN BUG ${r.id} — "${clipMemText(r.text)}" — ปิดด้วย fapony mem close ${r.id} "<msg>" เมื่อแก้แล้ว${movedFrom(r)}`,
146
- );
147
- openBugIds.push(r.id);
148
- }
149
- const base = basename(rel);
150
- // Tiers: files[] names this file > text/spec mentions the path >
151
- // basename-only. readMemLog is newest first, so each tier is too.
152
- const exact: typeof mem.rows = [];
153
- const mention: typeof mem.rows = [];
154
- const baseOnly: typeof mem.rows = [];
155
- for (const r of mem.rows) {
156
- if (r.kind === "claim" || r.kind === "release" || r.kind === "close") {
157
- continue;
158
- }
159
- if (filesOf(r).includes(rel)) {
160
- exact.push(r);
161
- continue;
162
- }
163
- const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
164
- if (hay.includes(rel)) mention.push(r);
165
- else if (base && hay.includes(base)) baseOnly.push(r);
166
- }
167
- let usableBase = baseOnly;
168
- if (baseOnly.length > 0) {
169
- const sameName = collectSourceFiles(worktree).filter(
170
- (f) => basename(f) === base,
171
- ).length;
172
- if (sameName !== 1) usableBase = [];
173
- }
174
- const shown =
175
- typeof session === "string" && session !== ""
176
- ? readShown(session)
177
- : null;
178
- // Open lines take the closed rows' slots first; the rest fills the
179
- // remaining budget without adding lines past the cap — and without
180
- // repeating a row already emitted as OPEN BUG or shown this session.
181
- const emitted = new Set(openBugIds);
182
- const rest = [exact, mention, usableBase]
183
- .flat()
184
- .filter((r) => !(r.id && (emitted.has(r.id) || shown?.has(r.id))))
185
- .slice(0, MEM_HINT_MAX - openLines.length);
186
- for (const line of openLines) memLines.push(line);
187
- memIds.push(...openBugIds);
188
- const restIds: string[] = [];
189
- for (const r of rest) {
190
- if (r.id) restIds.push(r.id);
191
- memLines.push(
192
- `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${clipMemText(r.text)}${movedFrom(r)}`,
193
- );
194
- }
195
- memIds.push(...restIds);
196
- if (shown && typeof session === "string") markShown(session, restIds);
197
- }
198
- return { worktree, debtIds, debtLines, memLines, openBugIds, memIds };
199
- } catch {
200
- return null;
201
- }
202
- }
203
-
204
- export function readContextLines(filePath: unknown, cwd: string): string[] {
205
- const data = readContextData(filePath, cwd);
206
- if (!data) return [];
207
- return [...data.debtLines, ...data.memLines].slice(
208
- 0,
209
- DEBT_HINT_MAX + MEM_HINT_MAX,
210
- );
211
- }