fapony 0.1.1 → 0.1.2
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/package.json +8 -7
- package/skill/plan-with-pony/SKILL.md +1 -1
- package/src/analyze.ts +9 -8
- package/src/conventions-seed.ts +10 -10
- package/src/db/index.ts +1 -1
- package/src/db/store.ts +4 -0
- package/src/debt.ts +26 -22
- package/src/digest/collect.ts +7 -7
- package/src/digest/html.ts +2 -2
- package/src/digest/text.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +119 -21
- package/src/init-mem.ts +3 -3
- package/src/install/opencode.ts +95 -8
- package/src/install/types.ts +1 -1
- package/src/install.ts +5 -1
- package/src/lint-baseline.ts +9 -9
- package/src/mcp/tools/mem.ts +1 -1
- package/src/mcp/tools/stats.ts +2 -2
- package/src/mcp/tools/usage.ts +3 -3
- package/src/mcp/tools/verdict.ts +29 -16
- package/src/mcp/transport.ts +1 -1
- package/src/plan-seed.ts +23 -21
- package/src/price/fetch.ts +19 -19
- package/src/price/resolve.ts +24 -24
- package/src/review-seed.ts +1 -1
- package/src/stats/data.ts +8 -7
- package/src/stats/format.ts +6 -5
- package/src/usage/render.ts +6 -5
- package/templates/PLAN.md +1 -1
- package/templates/mem/commands/plan.ts +44 -44
- package/templates/mem/commands/read.ts +5 -5
- package/templates/mem/commands/rotate.ts +6 -6
- package/templates/mem/commands/selftest.ts +14 -14
- package/templates/mem/commands/write.ts +13 -13
- package/templates/mem/mem.ts +5 -5
- package/templates/mem/selectors.ts +18 -18
- package/templates/mem/store.ts +50 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Measurement layer for coding agents — measure what agents do, verify what they claim. 6 MCP tools, any agent, no loop required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -26,17 +26,18 @@
|
|
|
26
26
|
"templates/",
|
|
27
27
|
"skill/"
|
|
28
28
|
],
|
|
29
|
-
"devDependencies": {
|
|
30
|
-
"@biomejs/biome": "^2.5.12",
|
|
31
|
-
"bun-types": "^1.4.0",
|
|
32
|
-
"typescript": "^5.9.3"
|
|
33
|
-
},
|
|
34
29
|
"scripts": {
|
|
35
30
|
"lint": "biome check .",
|
|
36
31
|
"typecheck": "tsc --noEmit",
|
|
37
32
|
"test": "bun fapony.ts test",
|
|
38
33
|
"test:fast": "SKIP_SLOW=1 bun fapony.ts test",
|
|
39
34
|
"check": "bun run lint && bun run typecheck && bun fapony.ts test",
|
|
35
|
+
"prepublishOnly": "bash scripts/smoke-publish.sh",
|
|
40
36
|
"overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.5.12",
|
|
40
|
+
"bun-types": "^1.4.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
}
|
|
@@ -102,7 +102,7 @@ the real scope instead.
|
|
|
102
102
|
So the seed buys you structure; the draft budget goes on judgment:
|
|
103
103
|
|
|
104
104
|
- **Read the Context block, then skip to filling** — everything else is the empty template.
|
|
105
|
-
- **Fill every section yourself** — §1–§6 and the TL;DR start as `_agent
|
|
105
|
+
- **Fill every section yourself** — §1–§6 and the TL;DR start as `_agent fills in_` slots.
|
|
106
106
|
- **Run the Phase −1 commands for facts** when the idea needs them, and put the numbers in the
|
|
107
107
|
section they answer — a number you measured beats a number the seed guessed at.
|
|
108
108
|
- **Signatures live in the SPEC chunks only.** Never paste them into plan §7 — link to the spec.
|
package/src/analyze.ts
CHANGED
|
@@ -376,7 +376,7 @@ export function diagnose(
|
|
|
376
376
|
findings.push({
|
|
377
377
|
kind: "cycle",
|
|
378
378
|
file: cycle.join(" ↔ "),
|
|
379
|
-
detail: "
|
|
379
|
+
detail: "circular imports — refactoring either side breaks the other",
|
|
380
380
|
evidence: [...cycle, cycle[0]].join(" → "),
|
|
381
381
|
});
|
|
382
382
|
}
|
|
@@ -389,7 +389,8 @@ export function diagnose(
|
|
|
389
389
|
findings.push({
|
|
390
390
|
kind: "orphan",
|
|
391
391
|
file: f,
|
|
392
|
-
detail:
|
|
392
|
+
detail:
|
|
393
|
+
"no one imports it and it is not an entry point — dead code candidate",
|
|
393
394
|
evidence: "0 dependents",
|
|
394
395
|
});
|
|
395
396
|
}
|
|
@@ -405,8 +406,8 @@ export function diagnose(
|
|
|
405
406
|
findings.push({
|
|
406
407
|
kind: "hub-untested",
|
|
407
408
|
file,
|
|
408
|
-
detail: `${n}
|
|
409
|
-
evidence:
|
|
409
|
+
detail: `${n} files depend on it; no test imports it — edit here and nothing catches the break`,
|
|
410
|
+
evidence: `dependents: ${shown}${rest}`,
|
|
410
411
|
});
|
|
411
412
|
}
|
|
412
413
|
|
|
@@ -417,10 +418,10 @@ export function diagnose(
|
|
|
417
418
|
findings.push({
|
|
418
419
|
kind: "changed-untested",
|
|
419
420
|
file: c,
|
|
420
|
-
detail:
|
|
421
|
+
detail: `recently changed but no test depends on it (${deps.size} dependent) — nothing catches it if it breaks`,
|
|
421
422
|
evidence:
|
|
422
423
|
deps.size > 0
|
|
423
|
-
?
|
|
424
|
+
? `dependents: ${[...deps].sort().join(", ")}`
|
|
424
425
|
: "0 dependents",
|
|
425
426
|
});
|
|
426
427
|
}
|
|
@@ -475,7 +476,7 @@ export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
|
|
|
475
476
|
lines.push("");
|
|
476
477
|
|
|
477
478
|
if (findings.length === 0) {
|
|
478
|
-
lines.push("no findings —
|
|
479
|
+
lines.push("no findings — structure looks healthy");
|
|
479
480
|
} else {
|
|
480
481
|
for (const f of findings.slice(0, 5)) {
|
|
481
482
|
const icon = f.kind === "orphan" ? "·" : "⚠";
|
|
@@ -488,7 +489,7 @@ export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
|
|
|
488
489
|
if (rest > 0) lines.push(`… and ${rest} more`);
|
|
489
490
|
lines.push(
|
|
490
491
|
graph.unresolved > 0
|
|
491
|
-
? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) —
|
|
492
|
+
? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) — dependent counts may be lower than reality`
|
|
492
493
|
: `${findings.length} findings.`,
|
|
493
494
|
);
|
|
494
495
|
}
|
package/src/conventions-seed.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// src/conventions-seed.ts — `fapony init` fill-signal (PLAN-convention-debt chunk 2).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// pair-mining
|
|
6
|
-
//
|
|
7
|
-
// eslint
|
|
3
|
+
// Question: "how far can signal-filling at init go" (SPEC-convention-debt §2.2) —
|
|
4
|
+
// Measured twice; the first answer was wrong: eslint is what most teams leave at default on install,
|
|
5
|
+
// pair-mining breaks on large commits and the cap cannot be relaxed (measured) — what **every repo has**
|
|
6
|
+
// is its own code, so the base is a wrapper detector (reads the snapshot only, never touches history),
|
|
7
|
+
// eslint is a bonus when present, pair-mining is not done yet (chunk 7 measures precision first)
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (rule 5c) · fapony
|
|
12
|
-
//
|
|
9
|
+
// Written to one place: <target>/.fapony/conventions.json — the convention definition lives in the repo being
|
|
10
|
+
// measured (SPEC §2.1), committable because every field is repo-relative · already exists = leave untouched
|
|
11
|
+
// (rule 5c) · fapony does not guess a convention with no trace — one never migrated and
|
|
12
|
+
// with no wrapper has no trace in either snapshot or history, that slot is left for a human to fill (stale)
|
|
13
13
|
|
|
14
14
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join, relative } from "node:path";
|
|
@@ -108,7 +108,7 @@ function rowsFromBlock(block: Block, whereBase: string): SeedRow[] {
|
|
|
108
108
|
whereGlob && whereGlob !== "." ? `${dir}${whereGlob}` : whereBase || ".";
|
|
109
109
|
const push = (message: unknown, stale: string | null, ruleId: string) => {
|
|
110
110
|
const text = flatText(message);
|
|
111
|
-
if (!text) return; // checker
|
|
111
|
+
if (!text) return; // a checker without a message = the rule is unknown
|
|
112
112
|
out.push({ id: "", rule: text, where, stale, checker: ruleId });
|
|
113
113
|
};
|
|
114
114
|
for (const [ruleId, value] of Object.entries(block.rules ?? {})) {
|
package/src/db/index.ts
CHANGED
package/src/db/store.ts
CHANGED
|
@@ -80,7 +80,11 @@ export function openDb(config?: Config): Database {
|
|
|
80
80
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
81
81
|
|
|
82
82
|
const db = new Database(`${dir}/state.db`);
|
|
83
|
+
// bun default busy_timeout=0 = no retry on SQLITE_BUSY; multiple agent
|
|
84
|
+
// processes share this db, so concurrent writers throw immediately without this.
|
|
85
|
+
db.run("PRAGMA busy_timeout=5000");
|
|
83
86
|
db.run("PRAGMA journal_mode=WAL");
|
|
87
|
+
db.run("PRAGMA synchronous=NORMAL");
|
|
84
88
|
|
|
85
89
|
migrateDb(db);
|
|
86
90
|
|
package/src/debt.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// src/debt.ts — `fapony debt`: which files have not moved to a shipped convention yet.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
3
|
+
// The question nobody can answer: "which files have not moved" — rules files
|
|
4
|
+
// (CLAUDE.md, Cursor rules) can only say "what the rule is" (layer 2) and
|
|
5
|
+
// "which files were copied" (layer 1) — where the debt is (layer 3) lives in
|
|
6
|
+
// the owner's head and vanishes when forgotten (SPEC-convention-debt §1)
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
// resolver
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// The convention definition lives in the measured repo (<repo>/.fapony/conventions.json
|
|
9
|
+
// — via the same resolver as the mem log, SPEC §2.1) — fapony does not know React
|
|
10
|
+
// or Hono and must not · one convention = pattern to use (ok) + pattern meaning
|
|
11
|
+
// not-yet-migrated (stale) + scope (where) + file condition (guard, e.g. extends Base)
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// fapony
|
|
15
|
-
//
|
|
13
|
+
// Debt is computed live every time, never written anywhere (same as analyze:
|
|
14
|
+
// a cache is pure debt — a frozen list goes stale silently like MASTER.md) ·
|
|
15
|
+
// Iron rule: checker not null = fapony does not report that debt item — reporting
|
|
16
|
+
// twice with eslint is an abstraction with one implementation (rule 1) and
|
|
17
|
+
// teaches the agent to skip both (SPEC §2)
|
|
16
18
|
//
|
|
17
19
|
// Read-only stdout: no file writes, no state.db, no cache (rule 5b).
|
|
18
20
|
|
|
@@ -242,12 +244,12 @@ export function debtScan(
|
|
|
242
244
|
const compiled: Compiled[] = [];
|
|
243
245
|
for (const conv of loaded.convs) {
|
|
244
246
|
if (conv.checker) {
|
|
245
|
-
//
|
|
247
|
+
// Iron rule — fapony stays silent, leave it to the checker (SPEC §2)
|
|
246
248
|
checkedCount++;
|
|
247
249
|
continue;
|
|
248
250
|
}
|
|
249
251
|
if (!conv.stale) {
|
|
250
|
-
//
|
|
252
|
+
// The one slot a human fills (SPEC §2.2) — show it as pending, don't guess
|
|
251
253
|
declared.push(conv);
|
|
252
254
|
continue;
|
|
253
255
|
}
|
|
@@ -352,16 +354,18 @@ export function debtForFile(
|
|
|
352
354
|
return out;
|
|
353
355
|
}
|
|
354
356
|
|
|
355
|
-
// --- Promotion signal (chunk 5) — "
|
|
357
|
+
// --- Promotion signal (chunk 5) — "this recurred N times, time for a checker?" ---
|
|
356
358
|
//
|
|
357
|
-
// "
|
|
358
|
-
//
|
|
359
|
-
//
|
|
359
|
+
// "I'll write eslint when I think of it" — the "think of it" moment is what goes
|
|
360
|
+
// missing (SPEC §3) · fapony sees history across sessions (mem + verdicts), so it
|
|
361
|
+
// can count how often the same thing was fixed, then put the question to a human —
|
|
362
|
+
// it does not decide, does not write the eslint rule itself (SPEC §6 fail list)
|
|
360
363
|
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// toLocaleString/useMutation — "throw"/"Error"
|
|
364
|
+
// Matching "the same thing" — only as precise as the data allows (SPEC §7: old rows
|
|
365
|
+
// lack files[], still undecided): a row with files[] must intersect the debt list ·
|
|
366
|
+
// the text must mention a convention symbol (ok such as fmtMoney, or an identifier
|
|
367
|
+
// ≥ 6 chars from stale such as toLocaleString/useMutation — "throw"/"Error" are too
|
|
368
|
+
// short and don't count, to avoid over-matching)
|
|
365
369
|
|
|
366
370
|
export const PROMOTION_THRESHOLD = 3;
|
|
367
371
|
const PROMOTION_MAX = 3;
|
|
@@ -642,7 +646,7 @@ export function cmdDebt(args: string[]): void {
|
|
|
642
646
|
}
|
|
643
647
|
|
|
644
648
|
if (loaded.path === null) {
|
|
645
|
-
// SPEC §6:
|
|
649
|
+
// SPEC §6: no conventions.json = completely silent, no error, no prompt to create one
|
|
646
650
|
console.log(
|
|
647
651
|
`fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
|
|
648
652
|
);
|
package/src/digest/collect.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// src/digest/collect.ts —
|
|
1
|
+
// src/digest/collect.ts — merge data from 4 sources into a single DigestData
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Renders nothing — just reads + shapes into structs
|
|
4
4
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
@@ -144,7 +144,7 @@ function parseFrontmatter(text: string): { status?: string; kind?: string } {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function countCheckboxes(text: string): { done: number; total: number } {
|
|
147
|
-
//
|
|
147
|
+
// Count checkboxes in the first ## section only (same logic as plans.ts)
|
|
148
148
|
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
149
149
|
const start = body.search(/^##\s+/m);
|
|
150
150
|
if (start < 0) return { done: 0, total: 0 };
|
|
@@ -216,7 +216,7 @@ function readUsageAndCost(_worktree: string): {
|
|
|
216
216
|
detail: string;
|
|
217
217
|
} {
|
|
218
218
|
const cache = readCache();
|
|
219
|
-
//
|
|
219
|
+
// Find a global entry (worktree=null), otherwise merge every entry
|
|
220
220
|
const globalEntries = cache.filter((e) => !e.worktree);
|
|
221
221
|
if (globalEntries.length === 0 && cache.length === 0) {
|
|
222
222
|
return {
|
|
@@ -232,11 +232,11 @@ function readUsageAndCost(_worktree: string): {
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
//
|
|
235
|
+
// Use global entries if present, otherwise merge every entry
|
|
236
236
|
const entries = globalEntries.length > 0 ? globalEntries : cache;
|
|
237
237
|
const usage = entriesToUsage(entries);
|
|
238
238
|
|
|
239
|
-
//
|
|
239
|
+
// Compute the price
|
|
240
240
|
const prices = loadPrices();
|
|
241
241
|
if (!prices) {
|
|
242
242
|
return {
|
|
@@ -271,7 +271,7 @@ function readUsageAndCost(_worktree: string): {
|
|
|
271
271
|
imputed: m.imputed_cost,
|
|
272
272
|
}));
|
|
273
273
|
|
|
274
|
-
//
|
|
274
|
+
// Attach the real cost from the cache
|
|
275
275
|
const cacheByKey = new Map(
|
|
276
276
|
entries.flatMap((e) =>
|
|
277
277
|
e.by_model.map((m) => [`${m.provider}\0${m.model}`, m]),
|
package/src/digest/html.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/digest/html.ts — render digest as a single HTML page
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// single column max-width: 68rem · no tabs, no collapse buttons, no JS
|
|
4
|
+
// uses DARK_THEME_CSS + TABLE_CSS from src/web/html.ts
|
|
5
5
|
|
|
6
6
|
import { DARK_THEME_CSS, esc, TABLE_CSS } from "../web/html.js";
|
|
7
7
|
import type {
|
package/src/digest/text.ts
CHANGED
package/src/gate.ts
CHANGED
|
@@ -53,9 +53,9 @@ export function gateOnce(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const config = loadConfig();
|
|
56
|
-
// runs.worktree
|
|
57
|
-
//
|
|
58
|
-
// MCP server
|
|
56
|
+
// runs.worktree stores an absolute path (SERVER_INSTRUCTIONS mandates it) — not a key in config.worktrees
|
|
57
|
+
// so the lookup always misses, and the old "." fallback ran the project's memory commands in the cwd of
|
|
58
|
+
// the MCP server instead of that project · the lookup stays in case an old row was stored as a key
|
|
59
59
|
const worktree = config.worktrees[run.worktree] ?? run.worktree;
|
|
60
60
|
|
|
61
61
|
// --- pass family (4 grades) ---
|
package/src/hook.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
|
|
2
2
|
// commits but no verdict.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Why a hook and not a message: SERVER_INSTRUCTIONS is a *request* that the
|
|
5
|
+
// agent remember, and it measured as not enough · the hook does not grade in
|
|
6
|
+
// the agent's place (it cannot — it does not see whether the work passed or
|
|
7
|
+
// broke) it merely won't let the turn end until the agent grades itself,
|
|
8
|
+
// separating "who judges" from "who forces judgment"
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
+
// The signal is a commit, not a dirty tree — dirty = still working, commit =
|
|
11
|
+
// the unit of work is done, matching the definition "1 run = 1 measurable
|
|
12
|
+
// unit of work" (rule 7)
|
|
10
13
|
//
|
|
11
|
-
//
|
|
14
|
+
// Two payloads, one decision — field-mapping only:
|
|
12
15
|
// claude {cwd, transcript_path, stop_hook_active} → {"decision":"block"}
|
|
13
16
|
// cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
|
|
14
|
-
// (cursor: loop_count ≥ 1 = hook
|
|
17
|
+
// (cursor: loop_count ≥ 1 = the hook already fired, status ≠ completed = allow)
|
|
15
18
|
|
|
16
19
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
17
20
|
import { homedir } from "node:os";
|
|
@@ -54,7 +57,7 @@ export function utcStamp(d: Date): string {
|
|
|
54
57
|
*
|
|
55
58
|
* PLAN-mem-mcp chunk 3: the block message now carries the commit list and the
|
|
56
59
|
* mem-log status (last row date). Both are *information*, never conditions —
|
|
57
|
-
* the block condition stays verdict-only (
|
|
60
|
+
* the block condition stays verdict-only (rule 7: the hook does not judge, it
|
|
58
61
|
* reports what is pending so the agent decides what deserves recording).
|
|
59
62
|
*/
|
|
60
63
|
export function decideStop(opts: {
|
|
@@ -224,7 +227,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
224
227
|
.get(worktree, since) as { n: number } | null;
|
|
225
228
|
verdicts = row?.n ?? 0;
|
|
226
229
|
// Informational only — read-only, degrade silently (mem status never
|
|
227
|
-
// becomes a block condition,
|
|
230
|
+
// becomes a block condition, rule 7).
|
|
228
231
|
try {
|
|
229
232
|
const mem = readMemLog(worktree);
|
|
230
233
|
memLastTs = mem.rows[0]?.ts ?? null;
|
|
@@ -251,15 +254,17 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
251
254
|
|
|
252
255
|
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
253
256
|
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
257
|
+
// Reading a large file in full is where an agent spends tokens without
|
|
258
|
+
// noticing — warnings in a skill were never enough (same principle as the
|
|
259
|
+
// Stop hook: speak while it is spending). But this hook **annotates only**:
|
|
260
|
+
// no permissionDecision, no "already read" dedupe — context compaction makes
|
|
261
|
+
// "already read" false, and a hook that guesses wrong and traps the agent is
|
|
262
|
+
// worse than no hook (the rule from the original hook.ts) — annotate cannot
|
|
263
|
+
// trap by construction, the worst cost of a miss is one unnecessary line
|
|
260
264
|
//
|
|
261
|
-
//
|
|
262
|
-
// estimate
|
|
265
|
+
// The text is facts only (line count + command + a one-time measurement), not
|
|
266
|
+
// a per-file estimate — guessing tokens is dressing up as data, against
|
|
267
|
+
// "facts only"
|
|
263
268
|
|
|
264
269
|
/** Below this size a full read is already cheap — stay silent. */
|
|
265
270
|
export const READ_HINT_MIN_BYTES = 24_000;
|
|
@@ -314,6 +319,98 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
314
319
|
}
|
|
315
320
|
}
|
|
316
321
|
|
|
322
|
+
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
323
|
+
//
|
|
324
|
+
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
325
|
+
// so it cannot block a turn; instead it appends an annotate to the bash tool
|
|
326
|
+
// output whenever there is a git commit with no verdict pending. It is the
|
|
327
|
+
// same kind of nudge as the read hint: no block, no dedupe, every unknown →
|
|
328
|
+
// silent · called from the opencode plugin by direct import (like
|
|
329
|
+
// readHintFor), no CLI subcommand because no client needs it as a subprocess
|
|
330
|
+
// (Cursor uses its own hook-stop instead)
|
|
331
|
+
//
|
|
332
|
+
// The text is facts only (commit list + verdict status), not an estimate
|
|
333
|
+
|
|
334
|
+
/** Below this number of commits, the hint is unnecessary noise. */
|
|
335
|
+
export const COMMIT_HINT_MIN_COMMITS = 1;
|
|
336
|
+
/** Cap commits shown in the hint message. */
|
|
337
|
+
const COMMIT_HINT_MAX_LIST = 5;
|
|
338
|
+
|
|
339
|
+
export interface CommitHintInput {
|
|
340
|
+
command: unknown;
|
|
341
|
+
cwd: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Nudge for bash commands containing `git commit` that produced
|
|
346
|
+
* ungraded commits. Returns a one-to-two line hint string, or null
|
|
347
|
+
* when there is nothing to nudge about (already graded, no commits,
|
|
348
|
+
* not a git commit command, not a git repo, any failure).
|
|
349
|
+
*
|
|
350
|
+
* Every unknown resolves to null — a hint must never fire on a
|
|
351
|
+
* guess. The work is cheap: one git rev-parse + one git log + one
|
|
352
|
+
* SQLite count.
|
|
353
|
+
*/
|
|
354
|
+
export function commitHintFor(opts: CommitHintInput): string | null {
|
|
355
|
+
try {
|
|
356
|
+
if (typeof opts.command !== "string" || opts.command === "") return null;
|
|
357
|
+
// Only fire on git commit commands — not `git push`, `git pull`, etc.
|
|
358
|
+
if (!/\bgit\s+commit\b/.test(opts.command)) return null;
|
|
359
|
+
|
|
360
|
+
const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
|
|
361
|
+
if (!worktree) return null;
|
|
362
|
+
|
|
363
|
+
// Window = commits since the worktree's last verdict, not "does a
|
|
364
|
+
// verdict exist anywhere in its history" — a worktree that earned one
|
|
365
|
+
// verdict months ago must still nudge on every commit made since, the
|
|
366
|
+
// same way cmdHookStop windows on `e.ts >= since` (session start) rather
|
|
367
|
+
// than "any verdict this worktree has ever had".
|
|
368
|
+
const db = openDb();
|
|
369
|
+
const lastVerdict = db
|
|
370
|
+
.query(
|
|
371
|
+
`SELECT MAX(e.ts) AS ts FROM events e JOIN runs r ON r.id = e.run_id
|
|
372
|
+
WHERE e.kind = 'gate' AND r.worktree = ?`,
|
|
373
|
+
)
|
|
374
|
+
.get(worktree) as { ts: string | null } | null;
|
|
375
|
+
// git's --since is inclusive to the second, and the commit a verdict
|
|
376
|
+
// just graded often lands in the same UTC second as the verdict itself
|
|
377
|
+
// (verdict_submit runs right after the commit) — bump by 1s so that
|
|
378
|
+
// commit isn't re-flagged as ungraded because of its own grade.
|
|
379
|
+
const since = lastVerdict?.ts
|
|
380
|
+
? utcStamp(
|
|
381
|
+
new Date(
|
|
382
|
+
new Date(`${lastVerdict.ts.replace(" ", "T")}Z`).getTime() + 1000,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
const log = since
|
|
388
|
+
? git(["log", "--since", `${since} +0000`, "--format=%h %s"], worktree)
|
|
389
|
+
: git(["log", "--format=%h %s"], worktree);
|
|
390
|
+
const commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
391
|
+
if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
|
|
392
|
+
|
|
393
|
+
const reason = decideStop({
|
|
394
|
+
stopHookActive: false, // annotate-only: never "already blocked"
|
|
395
|
+
worktree,
|
|
396
|
+
commits: commitList.length,
|
|
397
|
+
verdicts: 0, // every commit left in the window is, by construction, ungraded
|
|
398
|
+
commitList: commitList.slice(0, COMMIT_HINT_MAX_LIST),
|
|
399
|
+
});
|
|
400
|
+
if (!reason) return null;
|
|
401
|
+
|
|
402
|
+
// Prefix each line with "fapony:" so it's visually distinct
|
|
403
|
+
// from normal bash output in the agent's context.
|
|
404
|
+
const prefixed = reason
|
|
405
|
+
.split("\n")
|
|
406
|
+
.map((l) => `fapony: ${l}`)
|
|
407
|
+
.join("\n");
|
|
408
|
+
return prefixed;
|
|
409
|
+
} catch {
|
|
410
|
+
return null; // any failure = no hint
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
317
414
|
/** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out.
|
|
318
415
|
* No permissionDecision ever — the tool call always proceeds. */
|
|
319
416
|
export async function cmdHookReadHint(): Promise<void> {
|
|
@@ -355,10 +452,11 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
355
452
|
|
|
356
453
|
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
357
454
|
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
455
|
+
// The one moment paying down debt is worth tokens is when the file is already
|
|
456
|
+
// open — so hook-read-hint appends two things after the size hint: conventions
|
|
457
|
+
// the file still violates (debt detector, computed live) and mem rows that
|
|
458
|
+
// mention the file (across sessions) · **annotate only**, as before — no
|
|
459
|
+
// block, no dedupe, every unknown → silent · combined cap 5 lines (debt 3 · mem 2)
|
|
362
460
|
|
|
363
461
|
const DEBT_HINT_MAX = 3;
|
|
364
462
|
const MEM_HINT_MAX = 2;
|
package/src/init-mem.ts
CHANGED
|
@@ -31,9 +31,9 @@ export function cmdInitMem(args: string[]): void {
|
|
|
31
31
|
const worktreeKey = args.find((x) => !x.startsWith("-"));
|
|
32
32
|
const config = loadConfig();
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// no key given = the repo you are standing in — so `fapony init-mem --update` runs in anyone's project
|
|
35
|
+
// without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
|
|
36
|
+
// so it picks up that project's paths.memoryEntry by itself)
|
|
37
37
|
const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
|
|
38
38
|
if (!worktree) {
|
|
39
39
|
console.error(`unknown worktree key: ${worktreeKey}`);
|