fapony 0.3.4 → 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.
- package/README.md +13 -6
- package/package.json +4 -5
- package/skill/move-to-done/SKILL.md +13 -3
- package/skill/plan-with-pony/SKILL.md +6 -2
- package/src/adapters/cli.ts +12 -7
- package/src/adapters/hooks/bug-markers.ts +50 -0
- package/src/adapters/hooks/compute-hint-impact.ts +22 -5
- package/src/adapters/hooks/context-data.ts +16 -10
- package/src/adapters/hooks/index.ts +6 -0
- package/src/adapters/hooks/read-hint.ts +17 -1
- package/src/adapters/hooks/session-start.ts +17 -6
- package/src/adapters/hooks/stop.ts +132 -6
- package/src/adapters/mcp/tools/index.ts +6 -1
- package/src/adapters/mcp/tools/mem.ts +40 -121
- package/src/commands.ts +180 -0
- package/src/core/debt-format.ts +2 -2
- package/src/core/since.ts +26 -0
- package/src/debt/cli.ts +20 -6
- package/src/debt/load.ts +136 -2
- package/src/digest/collect.ts +1 -19
- package/src/hook.ts +4 -0
- package/src/mem/commands/plan.ts +399 -13
- package/src/mem/commands/read.ts +181 -36
- package/src/mem/commands/write.ts +26 -31
- package/src/mem/engine.ts +249 -0
- package/src/mem/index.ts +69 -5
- package/src/memory.ts +2 -1
- package/src/seed/plan-seed.ts +118 -28
- package/src/seed/review-seed.ts +34 -10
- package/src/update.ts +1 -1
- package/src/test.ts +0 -2
package/README.md
CHANGED
|
@@ -434,9 +434,10 @@ years:
|
|
|
434
434
|
- **There is no `MASTER.md`.** Every line above is derived from the plan files themselves, so it
|
|
435
435
|
cannot drift; a hand-kept master file always does.
|
|
436
436
|
|
|
437
|
-
`status` / `blocked_by` / `blocks` / `superseded_by` are read by
|
|
438
|
-
|
|
439
|
-
|
|
437
|
+
`status` / `blocked_by` / `blocks` / `superseded_by` are read by `fapony mem plan-check`
|
|
438
|
+
(dangling refs, blocker shipped but dependent still blocked, waiter cycles, blocked with all
|
|
439
|
+
chunks ticked) and by `fapony mem plan-sweep` (blocked view + a `🔓` unblock hint on `--apply`).
|
|
440
|
+
Sentence values ("waiting on support email") carry no `PLAN-*.md` token and are never flagged.
|
|
440
441
|
|
|
441
442
|
The layout, and why archiving is a plain `git mv`:
|
|
442
443
|
|
|
@@ -464,18 +465,25 @@ fapony price-scan # fetch model price table → prices.js
|
|
|
464
465
|
fapony usage-web [port] # live usage comparison dashboard from cache
|
|
465
466
|
fapony stats [--mode verdict [--regime code|fix|review|plan|inquiry|test]] # KPIs: pass/stall rate, by-model, by-grade — --mode verdict ranks by quality/tokens instead
|
|
466
467
|
fapony digest [--since 7d|YYYY-MM-DD] [--format text|html] [--json] [--out FILE] # single-page summary: decisions, open bugs, in-flight plans, cost, pass/fail — from what's already on disk
|
|
467
|
-
fapony plan-seed <name> [--spec] [--scope <path>]... # write PLAN (+SPEC): frontmatter, 8 empty sections, prior-art list,
|
|
468
|
+
fapony plan-seed <name> [--spec] [--scope <path>]... # write PLAN (+SPEC): frontmatter, 8 empty sections, prior-art list, mem-decision context + existing-in-scope; existing plans listed on stdout; SPEC chunks carry signatures, every section capped — the agent fills the judgment
|
|
468
469
|
fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>] # read-only scope facts for a review (changed files, importers, untested, signatures, plan cross-check)
|
|
469
470
|
|
|
470
471
|
# Memory & convention debt
|
|
471
472
|
fapony mem add <kind> "<text>" --files f1,f2 [spec.md] # append a mem row (decision/bug/note/next/hold)
|
|
472
473
|
fapony mem close <id> "<msg>" # close a bug
|
|
473
|
-
fapony mem find "<text>"
|
|
474
|
+
fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open] # search mem log
|
|
474
475
|
fapony mem kickoff [<plan.md>] # open a session + a next-up list
|
|
475
476
|
fapony mem where # show the resolved mem dir and which step won
|
|
476
477
|
fapony mem done | stale # views
|
|
477
478
|
fapony debt [--id <convention>] [--where <path>] # ไฟล์ไหนยังไม่ย้ายไป convention ที่ประกาศไว้ (live, read-only)
|
|
478
479
|
fapony lint-baseline [--cmd ...] [--diff] # separate "already red" from "I made it red"
|
|
480
|
+
|
|
481
|
+
# Hooks (wired by `fapony install`, not run by hand)
|
|
482
|
+
fapony hook-stop # Stop hook: block turns with commits but no mem row
|
|
483
|
+
fapony hook-read-hint # read/re-read annotations
|
|
484
|
+
fapony hook-edit-hint # importer count before editing shape
|
|
485
|
+
fapony hook-mv-guard # deny raw git mv of plan files into done/
|
|
486
|
+
fapony hook-session-start # SessionStart: kickoff into context
|
|
479
487
|
```
|
|
480
488
|
|
|
481
489
|
*When* to call `mem add` is your project's call, not fapony's — write it in your own
|
|
@@ -501,7 +509,6 @@ fapony install --dry-run # show what would happen without writi
|
|
|
501
509
|
fapony setup # interactive wizard: config + scaffold in one step
|
|
502
510
|
fapony update # self-update via git pull
|
|
503
511
|
fapony telemetry show|send # opt-in only, default off — see https://github.com/kire21b/fapony/blob/main/TELEMETRY.md
|
|
504
|
-
fapony test # self-check
|
|
505
512
|
```
|
|
506
513
|
|
|
507
514
|
## Config
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -29,11 +29,10 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"lint": "biome check .",
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
|
-
"test": "bun
|
|
33
|
-
"test:
|
|
34
|
-
"test:one": "bun scripts/test-one.ts",
|
|
32
|
+
"test": "bun test --timeout 20000 --parallel",
|
|
33
|
+
"test:changed": "bun test --timeout 20000 --changed",
|
|
35
34
|
"knip": "bunx knip@6 --exclude types,nsTypes || true",
|
|
36
|
-
"check": "bun run lint && bun run typecheck && bun
|
|
35
|
+
"check": "bun run lint && bun run typecheck && bun test --timeout 20000 --parallel",
|
|
37
36
|
"prepublishOnly": "bash scripts/smoke-publish.sh",
|
|
38
37
|
"release": "git checkout main && git pull --ff-only && npm version patch -m 'release v%s' && git push origin main --follow-tags",
|
|
39
38
|
"overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
|
|
@@ -21,7 +21,7 @@ You are about to move a PLAN that has been shipped to the archive.
|
|
|
21
21
|
Say in the summary that you stamped it, so a wrong HEAD is visible and correctable.
|
|
22
22
|
STOP only if there's no git repo / no commits to hash from.
|
|
23
23
|
|
|
24
|
-
1b. **A plan can also leave `plan/` without shipping** — it got absorbed into another plan, or the
|
|
24
|
+
1b. **A plan can also leave `plan/` without shipping** — it got absorbed into another plan, or the
|
|
25
25
|
redesign deleted the thing it planned. That is normal during a UI/UX sweep and is the main
|
|
26
26
|
reason `plan/` grows forever: there is no state for "dead" so it just sits there. Archive it
|
|
27
27
|
the same way, with two differences — header `> ⛔ **superseded by [PLAN-bar.md](PLAN-bar.md)**
|
|
@@ -37,12 +37,22 @@ You are about to move a PLAN that has been shipped to the archive.
|
|
|
37
37
|
A plan that is merely *waiting* (on a person, a customer, a decision) is **not** dead and does
|
|
38
38
|
not move — mark it `status: blocked` + `blocked_by: <what you are waiting for>` and leave it in
|
|
39
39
|
`plan/` — the frontmatter is for the next person reading the folder, and the plan stays out
|
|
40
|
-
of `done/`, which is what `plan-sweep` and `kickoff` go by.
|
|
40
|
+
of `done/`, which is what `plan-sweep` and `kickoff` go by. Never `--apply` a blocked file;
|
|
41
|
+
a blocked file with all chunks ticked is deferred doc debt — ask the user: ship it or keep
|
|
42
|
+
waiting.
|
|
43
|
+
|
|
44
|
+
1c. **Check the dep graph before moving** — `fapony mem plan-check` reads `blocked_by`/`blocks`
|
|
45
|
+
and says what a human would miss: a `blocked_by` pointing at a file that is not in `plan/`
|
|
46
|
+
or `done/`, a blocker already in `done/` while the dependent is still `status: blocked`,
|
|
47
|
+
a waiter cycle, and a blocked plan with all chunks ticked. Fix its issues first — a move
|
|
48
|
+
on top of a broken graph just relocates the confusion.
|
|
41
49
|
|
|
42
50
|
2. **Run `plan-sweep --apply`** — this does the `git mv`, rewrites markdown links inside the
|
|
43
51
|
file and inbound links from every `.md` under `.fapony/` (`plan/`, `done/`, `spec/`),
|
|
44
52
|
warns about plain-text mentions and about tracked files outside `.fapony/` that still
|
|
45
|
-
name the file
|
|
53
|
+
name the file, prints a `🔓 <shipped> — <waiter> lists it as blocker` line when the ship
|
|
54
|
+
unblocks a waiting plan (copy that line into your summary — the waiter keeps
|
|
55
|
+
`status: blocked` until its owner clears it), and logs a decision row — all in one call:
|
|
46
56
|
```bash
|
|
47
57
|
fapony mem plan-sweep <PLAN-foo.md> --apply
|
|
48
58
|
```
|
|
@@ -88,9 +88,13 @@ fapony plan-seed <feature> --spec --scope <path>
|
|
|
88
88
|
One command, no MCP round trip. It writes `<planDir>/PLAN-<feature>.md` +
|
|
89
89
|
`<specDir>/SPEC-<feature>.md` — the frontmatter, the 8 empty sections, a `## 8. References` list
|
|
90
90
|
of shipped plans that already touched this scope, and a `## Context (fapony)` block under the
|
|
91
|
-
TL;DR (recent mem decisions plus
|
|
91
|
+
TL;DR (recent mem decisions plus what is already in scope — one line per scope file with its
|
|
92
|
+
exports; needs `--scope` to list anything). No ledger-ranking line: the ledger is frozen and
|
|
93
|
+
cross-model ranking claims are off the table, so the seed does not point at them. SPEC chunks carry
|
|
92
94
|
verbatim signatures, hard-capped (PLAN ≤ ~60 / SPEC ≤ 200 lines), and capped lines say what was
|
|
93
|
-
cut.
|
|
95
|
+
cut. Stdout ends with the existing plan list (active first, then shipped) — the seed that lands
|
|
96
|
+
next to a shipped decision without knowing it is the expensive mistake, and the file guard in §8
|
|
97
|
+
is not where the glance lands. The CLI resolves plan-dir/spec-dir and refuses to overwrite (pick `-v2` — see Phase 2).
|
|
94
98
|
|
|
95
99
|
**§2/§5 arrive empty on purpose (2026-09-18).** They used to hold a repetition scan and analyze
|
|
96
100
|
findings; measured over every plan that ever used them, §5 printed "no findings" 3 times out of 3
|
package/src/adapters/cli.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { cmdAnalyze } from "../analyze.js";
|
|
9
|
+
import { renderUsage, suggestCommand } from "../commands.js";
|
|
9
10
|
import { cmdDebt } from "../debt/cli.js";
|
|
10
11
|
import { cmdDigest } from "../digest/cli.js";
|
|
11
12
|
import { cmdInit } from "../init.js";
|
|
12
13
|
import { cmdInitMem } from "../init-mem.js";
|
|
13
14
|
import { cmdInstall } from "../install.js";
|
|
14
15
|
import { cmdLintBaseline } from "../lint-baseline.js";
|
|
15
|
-
import { cmdMem } from "../mem/index.js";
|
|
16
|
+
import { cmdMem, MEM_SUBCOMMANDS } from "../mem/index.js";
|
|
16
17
|
import { initStore } from "../mem/store.js";
|
|
17
18
|
import { cmdPriceScan } from "../price/index.js";
|
|
18
19
|
import { cmdReport, cmdReportWeb } from "../report/index.js";
|
|
@@ -35,6 +36,11 @@ import { cmdMcp } from "./mcp/transport.js";
|
|
|
35
36
|
export async function cliMain(): Promise<void> {
|
|
36
37
|
const [cmd, ...a] = process.argv.slice(2);
|
|
37
38
|
|
|
39
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
40
|
+
console.log(renderUsage());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
if (cmd === "analyze") {
|
|
39
45
|
cmdAnalyze(a);
|
|
40
46
|
} else if (cmd === "debt") {
|
|
@@ -110,14 +116,13 @@ export async function cliMain(): Promise<void> {
|
|
|
110
116
|
await cmdPriceScan(a);
|
|
111
117
|
} else if (cmd === "usage-web") {
|
|
112
118
|
cmdUsageWeb(a);
|
|
113
|
-
} else if (cmd === "test") {
|
|
114
|
-
const { cmdTest } = await import("../test.js");
|
|
115
|
-
await cmdTest();
|
|
116
119
|
} else {
|
|
117
120
|
console.error(`fapony: unknown command "${cmd ?? ""}"`);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
const hints = suggestCommand(cmd ?? "", MEM_SUBCOMMANDS);
|
|
122
|
+
if (hints.length > 0) {
|
|
123
|
+
console.error(`did you mean ${hints.map((h) => `"${h}"`).join(" or ")}?`);
|
|
124
|
+
}
|
|
125
|
+
console.error(`usage: fapony <command> [args] — see "fapony --help"`);
|
|
121
126
|
process.exit(1);
|
|
122
127
|
}
|
|
123
128
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
export const BUG_MARKERS: RegExp[] = [
|
|
23
|
+
/เจอบั๊ก/,
|
|
24
|
+
/พบบั๊ก/,
|
|
25
|
+
/\bfound (?:a |the )?bug\b/i,
|
|
26
|
+
/\b(?:this|that|it)(?:'s| is) a bug\b/i,
|
|
27
|
+
/\bbug\b\s*:/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The matched marker phrase, or null. Returns the matched text so the caller
|
|
32
|
+
* can quote it back to the agent (stop.ts does, in its block reason).
|
|
33
|
+
*/
|
|
34
|
+
export function hasBugMarker(text: string): string | null {
|
|
35
|
+
for (const re of BUG_MARKERS) {
|
|
36
|
+
const m = text.match(re);
|
|
37
|
+
if (m) return m[0];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Conventional-commit bug types, anchored to the subject prefix — arbitrary
|
|
43
|
+
// prose containing "fix" ("pre-fix the cache") must not match. Language-free:
|
|
44
|
+
// the type token stays English even when the description is not.
|
|
45
|
+
const BUGFIX_COMMIT = /^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:/;
|
|
46
|
+
|
|
47
|
+
/** True when a commit subject declares a fix (the universal bug token). */
|
|
48
|
+
export function isBugfixCommit(subject: string): boolean {
|
|
49
|
+
return BUGFIX_COMMIT.test(subject.trim());
|
|
50
|
+
}
|
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
// Split from src/hook.ts (PLAN-lib-layer chunk 3). Reads hint log + re-runs
|
|
4
4
|
// debt detection to count resolved vs unresolved hints.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
8
14
|
import {
|
|
9
15
|
type HintFireRow,
|
|
10
16
|
type HintImpact,
|
|
11
17
|
hintLogDir,
|
|
12
18
|
worktreeKey,
|
|
13
19
|
} from "../../core/hint-log.js";
|
|
14
|
-
import { debtForFile,
|
|
20
|
+
import { debtForFile, resolveDebtScope } from "../../debt/index.js";
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Compute hint-fire impact from the log. `since` is an ISO date string;
|
|
@@ -80,14 +86,25 @@ export function computeHintImpact(
|
|
|
80
86
|
|
|
81
87
|
for (const [key, ids] of debtByFile) {
|
|
82
88
|
const [worktree, file] = key.split("\t");
|
|
83
|
-
|
|
89
|
+
let absFile = join(worktree, file);
|
|
84
90
|
let currentIds: Set<string>;
|
|
85
91
|
try {
|
|
86
92
|
if (!statSync(absFile).isFile()) {
|
|
87
93
|
impact.debt.unknown += ids.length;
|
|
88
94
|
continue;
|
|
89
95
|
}
|
|
90
|
-
|
|
96
|
+
// The log stores the worktree lexically; scanRoot is physical —
|
|
97
|
+
// resolve so debtForFile compares like with like.
|
|
98
|
+
try {
|
|
99
|
+
absFile = realpathSync(absFile);
|
|
100
|
+
} catch {
|
|
101
|
+
// keep the lexical form
|
|
102
|
+
}
|
|
103
|
+
// Same scope as the hint itself — resolving at the git root would
|
|
104
|
+
// load zero conventions in a monorepo and count every shown id as
|
|
105
|
+
// resolved (precision stuck at 100%).
|
|
106
|
+
const scope = resolveDebtScope(dirname(absFile));
|
|
107
|
+
const convs = debtForFile(scope.scanRoot, absFile, scope.loaded);
|
|
91
108
|
currentIds = new Set(convs.map((c) => c.id));
|
|
92
109
|
} catch {
|
|
93
110
|
impact.debt.unknown += ids.length;
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// edit-hint adapters to attach debt/mem lines when a file is open.
|
|
5
5
|
|
|
6
6
|
import { realpathSync } from "node:fs";
|
|
7
|
-
import { basename, join, relative } from "node:path";
|
|
7
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
8
8
|
import { collectSourceFiles, SCAN_EXTS } from "../../analyze.js";
|
|
9
|
-
import { debtForFile,
|
|
9
|
+
import { debtForFile, resolveDebtScope } from "../../debt/index.js";
|
|
10
10
|
import { readMemLog } from "../../memory.js";
|
|
11
11
|
|
|
12
12
|
const DEBT_HINT_MAX = 3;
|
|
@@ -44,21 +44,27 @@ export function readContextData(
|
|
|
44
44
|
const debtLines: string[] = [];
|
|
45
45
|
const memLines: string[] = [];
|
|
46
46
|
|
|
47
|
-
// convention debt — source files only, fresh from the repo
|
|
47
|
+
// convention debt — source files only, fresh from the repo. The scope
|
|
48
|
+
// pairs the git root (repo-relative `where`) with the nearest
|
|
49
|
+
// conventions file — anchoring the load at the root goes silent in a
|
|
50
|
+
// monorepo with app-scoped conventions (bug mucvfaxk).
|
|
48
51
|
const dot = rel.lastIndexOf(".");
|
|
49
52
|
if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
53
|
+
const scope = resolveDebtScope(dirname(abs));
|
|
54
|
+
for (const c of debtForFile(scope.scanRoot, abs, scope.loaded).slice(
|
|
55
|
+
0,
|
|
56
|
+
DEBT_HINT_MAX,
|
|
57
|
+
)) {
|
|
55
58
|
debtIds.push(c.id);
|
|
56
59
|
debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
// mem rows that are about this file
|
|
61
|
-
|
|
63
|
+
// mem rows that are about this file — resolve the log from the file's own
|
|
64
|
+
// directory, not the repo root. In a monorepo the log is app-scoped, so
|
|
65
|
+
// anchoring at the root sees only an out-of-scope candidate and goes silent
|
|
66
|
+
// even though the file being touched sits right under its log (bug muc9q47r).
|
|
67
|
+
const mem = readMemLog(dirname(abs));
|
|
62
68
|
if (mem.rows.length > 0) {
|
|
63
69
|
const base = basename(rel);
|
|
64
70
|
const direct: typeof mem.rows = [];
|
|
@@ -15,6 +15,11 @@ export {
|
|
|
15
15
|
sessionKey,
|
|
16
16
|
utcStamp,
|
|
17
17
|
} from "../../core/hook-helpers.js";
|
|
18
|
+
export {
|
|
19
|
+
BUG_MARKERS,
|
|
20
|
+
hasBugMarker,
|
|
21
|
+
isBugfixCommit,
|
|
22
|
+
} from "./bug-markers.js";
|
|
18
23
|
export { computeHintImpact } from "./compute-hint-impact.js";
|
|
19
24
|
export {
|
|
20
25
|
type ContextLineData,
|
|
@@ -64,6 +69,7 @@ export {
|
|
|
64
69
|
sessionStartContext,
|
|
65
70
|
} from "./session-start.js";
|
|
66
71
|
export {
|
|
72
|
+
bugSignalFromTranscript,
|
|
67
73
|
cmdHookStop,
|
|
68
74
|
cursorTranscriptPath,
|
|
69
75
|
decideStop,
|
|
@@ -18,6 +18,7 @@ import { recordHintFire } from "../../core/hint-log.js";
|
|
|
18
18
|
import { sessionKey } from "../../core/hook-helpers.js";
|
|
19
19
|
import { readMemLog } from "../../memory.js";
|
|
20
20
|
import { renderSeed } from "../../seed/review-seed.js";
|
|
21
|
+
import { hasBugMarker, isBugfixCommit } from "./bug-markers.js";
|
|
21
22
|
import { readContextData } from "./context-data.js";
|
|
22
23
|
|
|
23
24
|
// --- Read hint (size) ---
|
|
@@ -244,7 +245,9 @@ export function commitHintFor(opts: CommitHintInput): string | null {
|
|
|
244
245
|
|
|
245
246
|
let memLastTs: string | null = null;
|
|
246
247
|
try {
|
|
247
|
-
|
|
248
|
+
// Anchor at the dir the commit ran in, not the repo root: the log is
|
|
249
|
+
// app-scoped in a monorepo, and root resolution misses it (bug muc9q47r).
|
|
250
|
+
memLastTs = readMemLog(opts.cwd).rows[0]?.ts ?? null;
|
|
248
251
|
} catch {
|
|
249
252
|
memLastTs = null;
|
|
250
253
|
}
|
|
@@ -258,6 +261,13 @@ export function commitHintFor(opts: CommitHintInput): string | null {
|
|
|
258
261
|
const commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
259
262
|
if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
|
|
260
263
|
|
|
264
|
+
// %h %s — strip the short hash to test the subject alone.
|
|
265
|
+
const subjectOf = (c: string) => c.replace(/^\S+\s+/, "");
|
|
266
|
+
const bugCommits = commitList.filter(
|
|
267
|
+
(c) =>
|
|
268
|
+
isBugfixCommit(subjectOf(c)) || hasBugMarker(subjectOf(c)) !== null,
|
|
269
|
+
);
|
|
270
|
+
|
|
261
271
|
const lines: string[] = [
|
|
262
272
|
`${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
|
|
263
273
|
];
|
|
@@ -266,6 +276,12 @@ export function commitHintFor(opts: CommitHintInput): string | null {
|
|
|
266
276
|
lines.push(
|
|
267
277
|
`fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
|
|
268
278
|
);
|
|
279
|
+
if (bugCommits.length > 0) {
|
|
280
|
+
lines.push(
|
|
281
|
+
`${bugCommits.length} of these read as a bug (fix-type commit or found-a-bug wording) — use kind:bug so it surfaces later, not note:`,
|
|
282
|
+
);
|
|
283
|
+
lines.push(` fapony mem add bug "what broke" --files <files>`);
|
|
284
|
+
}
|
|
269
285
|
|
|
270
286
|
const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
|
|
271
287
|
return prefixed;
|
|
@@ -69,12 +69,23 @@ export function sessionStartContext(
|
|
|
69
69
|
cwd: string,
|
|
70
70
|
faponyTs: string = Bun.main,
|
|
71
71
|
): string | null {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
// Resolution shares one function with every reader. A bare monorepo root has
|
|
73
|
+
// no log at/above cwd, so the resolver reports the app-scoped log only as an
|
|
74
|
+
// out-of-scope candidate (SPEC §1). For a *read* at session start that is
|
|
75
|
+
// still recoverable: if the whole repo holds exactly one log, it is the
|
|
76
|
+
// project's memory — point kickoff at it with --mem-dir. Two or more is
|
|
77
|
+
// genuinely ambiguous (which app?), so refuse exactly as the resolver does.
|
|
78
|
+
const resolved = whereMemDir(cwd);
|
|
79
|
+
const memArgs: string[] = [];
|
|
80
|
+
if (!resolved.dir) {
|
|
81
|
+
const candidates = resolved.candidates ?? [];
|
|
82
|
+
if (candidates.length !== 1) return null;
|
|
83
|
+
memArgs.push("--mem-dir", candidates[0]);
|
|
84
|
+
}
|
|
85
|
+
const p = Bun.spawnSync(
|
|
86
|
+
[process.execPath, faponyTs, "mem", ...memArgs, "kickoff"],
|
|
87
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
88
|
+
);
|
|
78
89
|
const out = p.stdout.toString().trim();
|
|
79
90
|
if (p.exitCode !== 0 || !out) return null;
|
|
80
91
|
return capContext(out);
|
|
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { hookTsMs, sessionKey, utcStamp } from "../../core/hook-helpers.js";
|
|
17
17
|
import { readMemLog, whereMemDir } from "../../memory.js";
|
|
18
|
+
import { hasBugMarker } from "./bug-markers.js";
|
|
18
19
|
|
|
19
20
|
// --- Types ---
|
|
20
21
|
|
|
@@ -120,7 +121,8 @@ export function normalizeStopInput(
|
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
123
|
* Pure decision: block when this session produced commits but no mem row
|
|
123
|
-
* newer than session start exists
|
|
124
|
+
* newer than session start exists, or when the agent announced a bug but
|
|
125
|
+
* filed no kind:bug row.
|
|
124
126
|
*/
|
|
125
127
|
export function decideStop(opts: {
|
|
126
128
|
stopHookActive: boolean;
|
|
@@ -130,9 +132,23 @@ export function decideStop(opts: {
|
|
|
130
132
|
commitList?: string[];
|
|
131
133
|
memLastTs?: string | null;
|
|
132
134
|
memCandidates?: string[];
|
|
135
|
+
bugSignal?: string | null;
|
|
136
|
+
bugRowSinceStart?: boolean;
|
|
133
137
|
}): string | null {
|
|
134
138
|
if (opts.stopHookActive) return null;
|
|
135
139
|
if (!opts.worktree) return null;
|
|
140
|
+
|
|
141
|
+
// --- Bug-signal block (independent of commits) ---
|
|
142
|
+
if (opts.bugSignal && !opts.bugRowSinceStart) {
|
|
143
|
+
return [
|
|
144
|
+
`This turn reported a bug ("${opts.bugSignal}") but no mem row with kind:bug exists for this session.`,
|
|
145
|
+
`A bug described in chat is lost when the room closes — a decision row does not surface it.`,
|
|
146
|
+
` fapony mem add bug "<what is broken>" --files <files>`,
|
|
147
|
+
`Already filed elsewhere, or not a bug? End the turn again — this fires once per session.`,
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Commit block (original) ---
|
|
136
152
|
if (opts.commits < 1) return null;
|
|
137
153
|
if (!opts.memLastTs && !opts.memCandidates?.length) return null;
|
|
138
154
|
if (opts.since && opts.memLastTs) {
|
|
@@ -164,6 +180,11 @@ export function decideStop(opts: {
|
|
|
164
180
|
`${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
|
|
165
181
|
`files[] is required — a row without it is unfindable when you touch that file next session.`,
|
|
166
182
|
);
|
|
183
|
+
if (opts.bugSignal) {
|
|
184
|
+
lines.push(
|
|
185
|
+
`This turn announced a bug ("${opts.bugSignal}") — use kind:bug, not decision.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
167
188
|
return lines.join("\n");
|
|
168
189
|
}
|
|
169
190
|
|
|
@@ -182,6 +203,7 @@ const STOP_BLOCK_DIR = "stop-block";
|
|
|
182
203
|
interface StopBlockRow {
|
|
183
204
|
ts: string;
|
|
184
205
|
worktree: string;
|
|
206
|
+
kind?: string;
|
|
185
207
|
}
|
|
186
208
|
|
|
187
209
|
/** Absolute path of a session's block log — may not exist. */
|
|
@@ -192,12 +214,16 @@ export function stopBlockPath(session: string): string {
|
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
/**
|
|
195
|
-
* True when this session already blocked for this worktree — the caller
|
|
196
|
-
* lets the turn end. Records the block when it has not.
|
|
217
|
+
* True when this session already blocked for this worktree + kind — the caller
|
|
218
|
+
* then lets the turn end. Records the block when it has not.
|
|
219
|
+
*
|
|
220
|
+
* `kind` defaults to `"commit"` for backwards compatibility. Bug blocks use
|
|
221
|
+
* `"bug"` so they don't consume the commit block's quota.
|
|
197
222
|
*/
|
|
198
223
|
export function stopBlockedBefore(
|
|
199
224
|
session: string | null,
|
|
200
225
|
worktree: string,
|
|
226
|
+
kind: string = "commit",
|
|
201
227
|
): boolean {
|
|
202
228
|
if (!session) return false;
|
|
203
229
|
const path = stopBlockPath(session);
|
|
@@ -206,7 +232,8 @@ export function stopBlockedBefore(
|
|
|
206
232
|
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
207
233
|
if (!line) continue;
|
|
208
234
|
try {
|
|
209
|
-
|
|
235
|
+
const row = JSON.parse(line) as StopBlockRow;
|
|
236
|
+
if (row.worktree === worktree && (row.kind ?? "commit") === kind) {
|
|
210
237
|
return true;
|
|
211
238
|
}
|
|
212
239
|
} catch {
|
|
@@ -216,7 +243,7 @@ export function stopBlockedBefore(
|
|
|
216
243
|
}
|
|
217
244
|
const dir = join(path, "..");
|
|
218
245
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
219
|
-
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
246
|
+
const row: StopBlockRow = { ts: new Date().toISOString(), worktree, kind };
|
|
220
247
|
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
221
248
|
} catch {
|
|
222
249
|
return false;
|
|
@@ -224,6 +251,89 @@ export function stopBlockedBefore(
|
|
|
224
251
|
return false;
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
// --- Bug-signal detection ---
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Scan assistant text from a Claude transcript for bug markers. Reads only
|
|
258
|
+
* the tail of the file (last 200KB) to avoid parsing the full transcript.
|
|
259
|
+
* Returns the first matched marker word, or null.
|
|
260
|
+
*/
|
|
261
|
+
export function bugSignalFromTranscript(
|
|
262
|
+
transcriptPath: string,
|
|
263
|
+
sinceMs: number,
|
|
264
|
+
): string | null {
|
|
265
|
+
try {
|
|
266
|
+
const stat = statSync(transcriptPath);
|
|
267
|
+
// Cap at 10MB — hook must not stall turn-end
|
|
268
|
+
if (stat.size > 10 * 1024 * 1024) return null;
|
|
269
|
+
|
|
270
|
+
// Read tail to avoid parsing the full transcript
|
|
271
|
+
const tailBytes = Math.min(stat.size, 200 * 1024);
|
|
272
|
+
const fd = require("node:fs").openSync(transcriptPath, "r");
|
|
273
|
+
const buf = Buffer.alloc(tailBytes);
|
|
274
|
+
require("node:fs").readSync(
|
|
275
|
+
fd,
|
|
276
|
+
buf,
|
|
277
|
+
0,
|
|
278
|
+
tailBytes,
|
|
279
|
+
Math.max(0, stat.size - tailBytes),
|
|
280
|
+
);
|
|
281
|
+
require("node:fs").closeSync(fd);
|
|
282
|
+
|
|
283
|
+
const tail = buf.toString("utf-8");
|
|
284
|
+
// When reading from the middle of a large file, the first line is partial
|
|
285
|
+
const readingMidFile = tailBytes < stat.size;
|
|
286
|
+
const startIdx = readingMidFile ? tail.indexOf("\n") : -1;
|
|
287
|
+
const lines =
|
|
288
|
+
startIdx >= 0 ? tail.slice(startIdx + 1).split("\n") : tail.split("\n");
|
|
289
|
+
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
if (!line) continue;
|
|
292
|
+
// Transcript tail: untyped JSONL — the structural type below names only
|
|
293
|
+
// the fields this scan reads (biome noExplicitAny: no `any` annotation).
|
|
294
|
+
let o: {
|
|
295
|
+
message?: {
|
|
296
|
+
role?: string;
|
|
297
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
298
|
+
created_at?: string;
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
o = JSON.parse(line);
|
|
303
|
+
} catch {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const m = o.message;
|
|
307
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
308
|
+
// Skip messages older than session start
|
|
309
|
+
if (m.created_at) {
|
|
310
|
+
const msgMs = new Date(m.created_at).getTime();
|
|
311
|
+
if (!Number.isNaN(msgMs) && msgMs < sinceMs) continue;
|
|
312
|
+
}
|
|
313
|
+
for (const b of m.content) {
|
|
314
|
+
if (b.type !== "text" || typeof b.text !== "string") continue;
|
|
315
|
+
const hit = hasBugMarker(b.text);
|
|
316
|
+
if (hit) return hit;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* True when the mem log has at least one kind:bug row with ts >= since.
|
|
327
|
+
*/
|
|
328
|
+
function hasBugRowSince(worktree: string, since: string): boolean {
|
|
329
|
+
try {
|
|
330
|
+
const { rows } = readMemLog(worktree, since);
|
|
331
|
+
return rows.some((r) => r.kind === "bug");
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
227
337
|
function git(args: string[], cwd: string): string | null {
|
|
228
338
|
try {
|
|
229
339
|
const p = Bun.spawnSync(["git", ...args], {
|
|
@@ -262,6 +372,8 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
262
372
|
let commitList: string[] = [];
|
|
263
373
|
let memLastTs: string | null = null;
|
|
264
374
|
let memCandidates: string[] = [];
|
|
375
|
+
let bugSignal: string | null = null;
|
|
376
|
+
let bugRowSinceStart = false;
|
|
265
377
|
if (worktree && since) {
|
|
266
378
|
const log = git(
|
|
267
379
|
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
@@ -276,6 +388,14 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
276
388
|
} catch {
|
|
277
389
|
memLastTs = null;
|
|
278
390
|
}
|
|
391
|
+
// Bug-signal detection (independent of commits)
|
|
392
|
+
if (norm.transcriptPath && process.env.FAPONY_NO_BUG_BLOCK !== "1") {
|
|
393
|
+
const sinceMs = hookTsMs(since);
|
|
394
|
+
if (!Number.isNaN(sinceMs)) {
|
|
395
|
+
bugSignal = bugSignalFromTranscript(norm.transcriptPath, sinceMs);
|
|
396
|
+
if (bugSignal) bugRowSinceStart = hasBugRowSince(worktree, since);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
279
399
|
}
|
|
280
400
|
|
|
281
401
|
reason = decideStop({
|
|
@@ -286,11 +406,17 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
286
406
|
commitList,
|
|
287
407
|
memLastTs,
|
|
288
408
|
memCandidates,
|
|
409
|
+
bugSignal,
|
|
410
|
+
bugRowSinceStart,
|
|
289
411
|
});
|
|
290
412
|
if (
|
|
291
413
|
reason &&
|
|
292
414
|
worktree &&
|
|
293
|
-
stopBlockedBefore(
|
|
415
|
+
stopBlockedBefore(
|
|
416
|
+
norm.transcriptPath,
|
|
417
|
+
worktree,
|
|
418
|
+
bugSignal ? "bug" : "commit",
|
|
419
|
+
)
|
|
294
420
|
) {
|
|
295
421
|
reason = null;
|
|
296
422
|
}
|