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.
- package/README.md +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -8
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +2 -11
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -211
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -400
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -258
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -394
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -792
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -284
- package/src/mem/engine.ts +0 -329
- package/src/mem/index.ts +0 -201
- package/src/mem/key-registry.ts +0 -116
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -227
- package/src/mem/store.ts +0 -356
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
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 {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
rows
|
|
29
|
-
|
|
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 =
|
|
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 =
|
|
425
|
+
const openN = openRowsFor(spec).filter((r) => r.kind === "bug").length;
|
|
422
426
|
const warn = openN
|
|
423
|
-
? ` ⚠ ${openN} open
|
|
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 =
|
|
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
|
|
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
|
-
|
|
481
|
-
? `${target}:
|
|
482
|
-
:
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
if (
|
|
517
|
-
|
|
518
|
-
|
|
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 =
|
|
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)` : ""}`,
|
package/src/seed/plan-seed.ts
CHANGED
|
@@ -47,9 +47,8 @@ import {
|
|
|
47
47
|
planDir,
|
|
48
48
|
specDir,
|
|
49
49
|
} from "../core/config.js";
|
|
50
|
-
import {
|
|
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 =
|
|
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
|
|
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 · \`
|
|
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
|
|
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
|
|
605
|
-
if (
|
|
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 (
|
|
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
|
|
228
|
-
│ fapony install
|
|
229
|
-
│ fapony install --platform claude │
|
|
210
|
+
│ 1. Wire fapony into your clients: │
|
|
211
|
+
│ fapony install │
|
|
230
212
|
│ │
|
|
231
|
-
│ 2.
|
|
232
|
-
│
|
|
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/
|
|
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 =
|
|
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
|
-
}
|