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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Every schema here is paid as input tokens in *every* session of *every*
|
|
4
4
|
// client that connects, whether or not the tool is called. Adding one is
|
|
5
5
|
// buying attention with a standing charge; earning it back means the tool
|
|
6
|
-
// saves more than it costs (see CLAUDE.md "
|
|
6
|
+
// saves more than it costs (see CLAUDE.md "Spend tokens smart"). Keep
|
|
7
7
|
// descriptions imperative — say what to send, not why it matters.
|
|
8
8
|
|
|
9
9
|
export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
|
|
@@ -59,6 +59,11 @@ export const TOOLS = [
|
|
|
59
59
|
description:
|
|
60
60
|
"Max rows returned (default 20) — total still counts all matches",
|
|
61
61
|
},
|
|
62
|
+
open: {
|
|
63
|
+
type: "boolean",
|
|
64
|
+
description:
|
|
65
|
+
"true = unresolved work only: drops close/claim/release/synced rows and work rows already closed. Omit = every row. With kind:[bug] answers 'what bugs remain?'",
|
|
66
|
+
},
|
|
62
67
|
},
|
|
63
68
|
required: ["worktree"],
|
|
64
69
|
},
|
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
// file is unfindable when you touch that file (fill rate 26% CLI-flag vs 88%
|
|
7
7
|
// MCP-required — schema wins over prose every time, see CLAUDE.md rule 9).
|
|
8
8
|
|
|
9
|
-
import { openRows } from "../../../mem/selectors.js";
|
|
10
9
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "../../../mem/store.js";
|
|
10
|
+
type EngineAddResult,
|
|
11
|
+
type EngineCloseResult,
|
|
12
|
+
engineAdd,
|
|
13
|
+
engineClose,
|
|
14
|
+
engineFind,
|
|
15
|
+
} from "../../../mem/engine.js";
|
|
16
|
+
import { initStore, KINDS, type WorkKind } from "../../../mem/store.js";
|
|
18
17
|
import { type MemRow, readMemLog, resolveMemDir } from "../../../memory.js";
|
|
19
18
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
20
19
|
|
|
@@ -33,37 +32,24 @@ export function memFind(args: {
|
|
|
33
32
|
kind?: string[];
|
|
34
33
|
since?: string;
|
|
35
34
|
limit?: number;
|
|
35
|
+
open?: boolean;
|
|
36
36
|
}): MemFindResult {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// text/spec/ref substring stays as the fallback: low recall by nature,
|
|
52
|
-
// a limit of the data rather than of the query (spec §5.4).
|
|
53
|
-
const paths = args.files.map((f) => f.toLowerCase());
|
|
54
|
-
rows = rows.filter((r) => {
|
|
55
|
-
const stored = (r.files ?? []).map((f) => f.toLowerCase());
|
|
56
|
-
if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
|
|
57
|
-
return true;
|
|
58
|
-
const hay = `${r.text}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
|
|
59
|
-
return paths.some((p) => hay.includes(p));
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const total = rows.length;
|
|
64
|
-
const limit = Math.max(0, args.limit ?? 20);
|
|
37
|
+
// Query logic lives in the shared engine (src/mem/engine.ts) — this wrapper
|
|
38
|
+
// owns only the read (readMemLog sees live + rotated archives via its loose
|
|
39
|
+
// log*.jsonl regex) and the result shape. No kind default here by contract:
|
|
40
|
+
// omitting kind returns every kind (locked by test). CLI cmdFind calls the
|
|
41
|
+
// same engine with its own bookkeeping exclude.
|
|
42
|
+
const read = readMemLog(args.worktree);
|
|
43
|
+
const { rows: matched, total } = engineFind(read.rows, {
|
|
44
|
+
text: args.text,
|
|
45
|
+
files: args.files,
|
|
46
|
+
kind: args.kind,
|
|
47
|
+
sinceIso: args.since,
|
|
48
|
+
limit: args.limit,
|
|
49
|
+
open: args.open,
|
|
50
|
+
});
|
|
65
51
|
return {
|
|
66
|
-
rows:
|
|
52
|
+
rows: matched,
|
|
67
53
|
total,
|
|
68
54
|
filesFound: read.filesFound,
|
|
69
55
|
skipped: read.skipped,
|
|
@@ -100,24 +86,25 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
|
|
|
100
86
|
: undefined;
|
|
101
87
|
const text = typeof args.text === "string" ? args.text : undefined;
|
|
102
88
|
const since = typeof args.since === "string" ? args.since : undefined;
|
|
89
|
+
if (since !== undefined && Number.isNaN(Date.parse(since))) {
|
|
90
|
+
return errorResult(
|
|
91
|
+
`since must be an ISO date (e.g. 2026-09-19T00:00:00.000Z), got: "${since}" — CLI accepts <N>d/YYYY-MM-DD, MCP takes ISO only`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
103
94
|
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
95
|
+
const open = typeof args.open === "boolean" ? args.open : undefined;
|
|
104
96
|
|
|
105
|
-
return jsonResult(
|
|
97
|
+
return jsonResult(
|
|
98
|
+
memFind({ worktree, files, text, kind, since, limit, open }),
|
|
99
|
+
);
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
// --- mem_add ---
|
|
103
|
+
//
|
|
104
|
+
// Domain rules live in the shared engine (src/mem/engine.ts) — this wrapper
|
|
105
|
+
// owns only store init for its worktree. CLI cmdAdd calls the same engine.
|
|
109
106
|
|
|
110
|
-
export
|
|
111
|
-
id: string;
|
|
112
|
-
kind: string;
|
|
113
|
-
text: string;
|
|
114
|
-
files: string[];
|
|
115
|
-
spec?: string;
|
|
116
|
-
ts: string;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const CAP_NEXT = 15;
|
|
120
|
-
const CAP_HOLD = 10;
|
|
107
|
+
export type MemAddResult = EngineAddResult;
|
|
121
108
|
|
|
122
109
|
export function memAdd(args: {
|
|
123
110
|
worktree: string;
|
|
@@ -126,62 +113,13 @@ export function memAdd(args: {
|
|
|
126
113
|
files: string[];
|
|
127
114
|
spec?: string;
|
|
128
115
|
}): MemAddResult {
|
|
129
|
-
if (!KINDS.includes(args.kind as WorkKind)) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`kind must be one of ${KINDS.join("|")} — got "${args.kind}"`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
if (args.files.length === 0) {
|
|
135
|
-
throw new Error("files must contain at least one path");
|
|
136
|
-
}
|
|
137
|
-
if (!args.text.trim()) {
|
|
138
|
-
throw new Error("text is required and must not be empty");
|
|
139
|
-
}
|
|
140
|
-
// CLI cmdAdd rejects hold without a spec (write.ts) — keep the two writers
|
|
141
|
-
// in lockstep: without a spec a hold can never be resolved by rotate.
|
|
142
|
-
if (args.kind === "hold" && !args.spec) {
|
|
143
|
-
throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
116
|
initStore(args.worktree);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// Cap check — matches CLI cmdAdd behaviour
|
|
150
|
-
if (args.kind === "next" && !process.env.MEM_FORCE) {
|
|
151
|
-
const openNext = openRows(all).filter((r) => r.kind === "next").length;
|
|
152
|
-
if (openNext >= CAP_NEXT) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
`open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (args.kind === "hold" && !process.env.MEM_FORCE) {
|
|
159
|
-
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
160
|
-
if (openHold >= CAP_HOLD) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const id = nextId(all);
|
|
168
|
-
const ts = new Date().toISOString();
|
|
169
|
-
put({
|
|
170
|
-
id,
|
|
171
|
-
kind: args.kind as WorkKind,
|
|
172
|
-
text: args.text,
|
|
173
|
-
spec: args.spec,
|
|
174
|
-
files: args.files,
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
id,
|
|
117
|
+
return engineAdd({
|
|
179
118
|
kind: args.kind,
|
|
180
119
|
text: args.text,
|
|
181
120
|
files: args.files,
|
|
182
121
|
spec: args.spec,
|
|
183
|
-
|
|
184
|
-
};
|
|
122
|
+
});
|
|
185
123
|
}
|
|
186
124
|
|
|
187
125
|
export function toolMemAdd(args: Record<string, unknown>): ToolResult {
|
|
@@ -246,34 +184,15 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
|
|
|
246
184
|
// (commands/write.ts cmdClose): the id must exist; the tombstone voids the
|
|
247
185
|
// claim by itself.
|
|
248
186
|
|
|
249
|
-
export
|
|
250
|
-
ref: string;
|
|
251
|
-
text: string;
|
|
252
|
-
ts: string;
|
|
253
|
-
}
|
|
187
|
+
export type MemCloseResult = EngineCloseResult;
|
|
254
188
|
|
|
255
189
|
export function memClose(args: {
|
|
256
190
|
worktree: string;
|
|
257
191
|
id: string;
|
|
258
192
|
text: string;
|
|
259
193
|
}): MemCloseResult {
|
|
260
|
-
if (!args.id.trim()) {
|
|
261
|
-
throw new Error("id is required");
|
|
262
|
-
}
|
|
263
|
-
if (!args.text.trim()) {
|
|
264
|
-
throw new Error("text is required and must not be empty");
|
|
265
|
-
}
|
|
266
|
-
|
|
267
194
|
initStore(args.worktree);
|
|
268
|
-
|
|
269
|
-
if (!all.some((r) => "id" in r && r.id === args.id)) {
|
|
270
|
-
throw new Error(`no id "${args.id}" in the log`);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const ts = new Date().toISOString();
|
|
274
|
-
put({ kind: "close", ref: args.id, text: args.text });
|
|
275
|
-
|
|
276
|
-
return { ref: args.id, text: args.text, ts };
|
|
195
|
+
return engineClose({ id: args.id, text: args.text });
|
|
277
196
|
}
|
|
278
197
|
|
|
279
198
|
export function toolMemClose(args: Record<string, unknown>): ToolResult {
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/commands.ts — the single source of truth for top-level command names.
|
|
2
|
+
//
|
|
3
|
+
// Dispatch stays an if-chain in src/adapters/cli.ts (rule 1: this is a data
|
|
4
|
+
// table, not a framework — no registry class, no plugin hooks). This table
|
|
5
|
+
// owns name + group + one-line summary; `fapony --help` renders from it and
|
|
6
|
+
// test/docs.test.ts fails when a name here is missing from the docs, so the
|
|
7
|
+
// four copies (if-chain, usage, CLAUDE.md, README) cannot drift silently.
|
|
8
|
+
// Zero imports — cli.ts loads this on every startup (≤ ~100ms budget).
|
|
9
|
+
|
|
10
|
+
export type CommandGroup =
|
|
11
|
+
| "core"
|
|
12
|
+
| "usage"
|
|
13
|
+
| "lookup"
|
|
14
|
+
| "hooks"
|
|
15
|
+
| "setup"
|
|
16
|
+
| "frozen";
|
|
17
|
+
|
|
18
|
+
export interface CommandInfo {
|
|
19
|
+
name: string;
|
|
20
|
+
group: CommandGroup;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GROUP_ORDER: CommandGroup[] = [
|
|
25
|
+
"core",
|
|
26
|
+
"usage",
|
|
27
|
+
"lookup",
|
|
28
|
+
"hooks",
|
|
29
|
+
"setup",
|
|
30
|
+
"frozen",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const COMMANDS: CommandInfo[] = [
|
|
34
|
+
{
|
|
35
|
+
name: "mem",
|
|
36
|
+
group: "core",
|
|
37
|
+
summary: "project pain memory: decisions, bugs, notes",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "debt",
|
|
41
|
+
group: "core",
|
|
42
|
+
summary: "which files haven't migrated to a declared convention",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "lint-baseline",
|
|
46
|
+
group: "core",
|
|
47
|
+
summary: 'separate "already red" from "I made it red"',
|
|
48
|
+
},
|
|
49
|
+
{ name: "init-mem", group: "core", summary: "delete legacy .memory/ dirs" },
|
|
50
|
+
{
|
|
51
|
+
name: "digest",
|
|
52
|
+
group: "core",
|
|
53
|
+
summary: "single-page summary from what's on disk",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "usage-scan",
|
|
57
|
+
group: "usage",
|
|
58
|
+
summary: "scan session logs into the cache",
|
|
59
|
+
},
|
|
60
|
+
{ name: "usage-web", group: "usage", summary: "usage dashboard from cache" },
|
|
61
|
+
{
|
|
62
|
+
name: "price-scan",
|
|
63
|
+
group: "usage",
|
|
64
|
+
summary: "refresh the model price table",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "analyze",
|
|
68
|
+
group: "lookup",
|
|
69
|
+
summary: "live repo graph: hubs, orphans, cycles",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "review-seed",
|
|
73
|
+
group: "lookup",
|
|
74
|
+
summary: "read-only scope facts for a review",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "plan-seed",
|
|
78
|
+
group: "lookup",
|
|
79
|
+
summary: "write PLAN (+SPEC) with capped sections",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "mcp",
|
|
83
|
+
group: "hooks",
|
|
84
|
+
summary: "MCP server (stdio JSON-RPC, 3 tools)",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "hook-stop",
|
|
88
|
+
group: "hooks",
|
|
89
|
+
summary: "Stop hook: block turns with commits but no mem row",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "hook-read-hint",
|
|
93
|
+
group: "hooks",
|
|
94
|
+
summary: "read/re-read annotations",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "hook-edit-hint",
|
|
98
|
+
group: "hooks",
|
|
99
|
+
summary: "importer count before editing shape",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "hook-mv-guard",
|
|
103
|
+
group: "hooks",
|
|
104
|
+
summary: "deny raw git mv of plan files into done/",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "hook-session-start",
|
|
108
|
+
group: "hooks",
|
|
109
|
+
summary: "SessionStart: kickoff into context",
|
|
110
|
+
},
|
|
111
|
+
{ name: "init", group: "setup", summary: "scaffold .fapony/ in a worktree" },
|
|
112
|
+
{
|
|
113
|
+
name: "install",
|
|
114
|
+
group: "setup",
|
|
115
|
+
summary: "wire MCP + skills into clients",
|
|
116
|
+
},
|
|
117
|
+
{ name: "setup", group: "setup", summary: "interactive wizard" },
|
|
118
|
+
{ name: "update", group: "setup", summary: "self-update via git pull" },
|
|
119
|
+
{ name: "telemetry", group: "setup", summary: "opt-in telemetry show/send" },
|
|
120
|
+
{
|
|
121
|
+
name: "stats",
|
|
122
|
+
group: "frozen",
|
|
123
|
+
summary: "frozen-ledger KPIs (reads history only)",
|
|
124
|
+
},
|
|
125
|
+
{ name: "report", group: "frozen", summary: "verification report for a run" },
|
|
126
|
+
{ name: "report-web", group: "frozen", summary: "static HTML report page" },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// Grouped names only — the shape PLAN-seed-and-surface §7 picked. Starts with
|
|
130
|
+
// a `usage: fapony` line: scripts/smoke-publish.sh greps for it on a no-arg run.
|
|
131
|
+
export const renderUsage = (): string => {
|
|
132
|
+
const lines = [
|
|
133
|
+
"fapony — project pain memory for agent-written code",
|
|
134
|
+
"usage: fapony <command> [args]",
|
|
135
|
+
"",
|
|
136
|
+
];
|
|
137
|
+
for (const g of GROUP_ORDER) {
|
|
138
|
+
const names = COMMANDS.filter((c) => c.group === g).map((c) => c.name);
|
|
139
|
+
lines.push(` ${g.padEnd(8)}${names.join(" ")}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(
|
|
142
|
+
"",
|
|
143
|
+
" fapony mem <sub> --help per-subcommand details (only mem has --help)",
|
|
144
|
+
);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Classic two-row Levenshtein — the did-you-mean behind unknown-command errors.
|
|
149
|
+
export const levenshtein = (a: string, b: string): number => {
|
|
150
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
151
|
+
for (let i = 1; i <= a.length; i++) {
|
|
152
|
+
const cur = [i];
|
|
153
|
+
for (let j = 1; j <= b.length; j++) {
|
|
154
|
+
cur[j] = Math.min(
|
|
155
|
+
prev[j] + 1,
|
|
156
|
+
cur[j - 1] + 1,
|
|
157
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
prev = cur;
|
|
161
|
+
}
|
|
162
|
+
return prev[b.length];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Up to 3 suggestions within distance ≤ 2, against top-level names and mem
|
|
166
|
+
// subs (so `fapony plan-check` offers `fapony mem plan-check`). Sorted by
|
|
167
|
+
// distance, then alphabetically — deterministic for tests.
|
|
168
|
+
export const suggestCommand = (cmd: string, memSubs: string[]): string[] => {
|
|
169
|
+
const scored: Array<{ text: string; d: number }> = [];
|
|
170
|
+
for (const c of COMMANDS) {
|
|
171
|
+
const d = levenshtein(cmd, c.name);
|
|
172
|
+
if (d <= 2) scored.push({ text: `fapony ${c.name}`, d });
|
|
173
|
+
}
|
|
174
|
+
for (const s of memSubs) {
|
|
175
|
+
const d = levenshtein(cmd, s);
|
|
176
|
+
if (d <= 2) scored.push({ text: `fapony mem ${s}`, d });
|
|
177
|
+
}
|
|
178
|
+
scored.sort((x, y) => x.d - y.d || (x.text < y.text ? -1 : 1));
|
|
179
|
+
return scored.slice(0, 3).map((s) => s.text);
|
|
180
|
+
};
|
package/src/core/debt-format.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function formatDebt(report: DebtReport, showAll = false): string {
|
|
|
77
77
|
for (let i = 0; i < cap; i++) {
|
|
78
78
|
const [zone, zoneFiles] = zoneEntries[i];
|
|
79
79
|
const pad = " ".repeat(Math.max(0, 42 - zone.length));
|
|
80
|
-
lines.push(`\n ${zone}${pad}${zoneFiles.length}
|
|
80
|
+
lines.push(`\n ${zone}${pad}${zoneFiles.length} files`);
|
|
81
81
|
lines.push(` ${zoneFiles.map((f) => f.split("/").pop()).join(" · ")}`);
|
|
82
82
|
totalCapped += zoneFiles.length;
|
|
83
83
|
}
|
|
@@ -85,7 +85,7 @@ export function formatDebt(report: DebtReport, showAll = false): string {
|
|
|
85
85
|
const remaining = e.files.length - totalCapped;
|
|
86
86
|
const remainingZones = zoneEntries.length - cap;
|
|
87
87
|
lines.push(
|
|
88
|
-
`\n …
|
|
88
|
+
`\n … ${remainingZones} more zones (${remaining} files) — fapony debt --id ${e.conv.id} --all`,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/core/since.ts — parse `--since` (Nd | YYYY-MM-DD) into an ISO timestamp.
|
|
2
|
+
//
|
|
3
|
+
// Pure date math, no imports. Two callers share it: digest/collect.ts and
|
|
4
|
+
// mem find (CLI --since + MCP sinceIso passthrough). It lives in core because
|
|
5
|
+
// both are features and core never imports back up (PLAN-unify-mem-engine
|
|
6
|
+
// chunk 2 — digest/collect.ts used to own this, but importing it from mem
|
|
7
|
+
// would drag db/store + hook.js into the mem load path).
|
|
8
|
+
|
|
9
|
+
export function parseSince(
|
|
10
|
+
raw: string | undefined,
|
|
11
|
+
now?: number,
|
|
12
|
+
): { iso: string; label: string } {
|
|
13
|
+
const def = "7d";
|
|
14
|
+
const s = raw ?? def;
|
|
15
|
+
const dMatch = /^(\d+)d$/.exec(s);
|
|
16
|
+
if (dMatch) {
|
|
17
|
+
const days = Number(dMatch[1]);
|
|
18
|
+
const dt = new Date((now ?? Date.now()) - days * 86400000);
|
|
19
|
+
return { iso: dt.toISOString(), label: `${days}d` };
|
|
20
|
+
}
|
|
21
|
+
const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(s);
|
|
22
|
+
if (dateMatch) {
|
|
23
|
+
return { iso: `${s}T00:00:00.000Z`, label: s };
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`invalid --since format: "${s}" — use <N>d or YYYY-MM-DD`);
|
|
26
|
+
}
|
package/src/debt/cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
|
|
|
6
6
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
7
|
import { CONVENTIONS_FILE } from "../core/config.js";
|
|
8
8
|
import { formatDebt } from "./format.js";
|
|
9
|
-
import {
|
|
9
|
+
import { resolveDebtScope } from "./load.js";
|
|
10
10
|
import { findPromotions, formatPromotions } from "./promotion.js";
|
|
11
11
|
import { debtForFile, debtScan } from "./scan.js";
|
|
12
12
|
import { ZONE_CAP } from "./types.js";
|
|
@@ -119,12 +119,26 @@ export function cmdDebt(args: string[]): void {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Scan scope: the git root (convention `where` values are repo-relative)
|
|
123
|
+
// paired with the nearest conventions file — scanning from the app dir
|
|
124
|
+
// dropped repo-relative conventions and hid files outside the app
|
|
125
|
+
// (bug mucvfiv5). Evidence (mem/ledger) keeps the old neighborhood scope.
|
|
126
|
+
// Physical path (symlinks resolved) so --files resolution below compares
|
|
127
|
+
// like with like against scope.scanRoot (/var → /private/var on macOS).
|
|
128
|
+
let base = resolve(path ?? ".");
|
|
129
|
+
try {
|
|
130
|
+
base = realpathSync(base);
|
|
131
|
+
} catch {
|
|
132
|
+
// nonexistent — the not-found notes below handle it
|
|
133
|
+
}
|
|
134
|
+
const scope = resolveDebtScope(base);
|
|
135
|
+
const worktree = scope.scanRoot;
|
|
136
|
+
const loaded = scope.loaded;
|
|
137
|
+
const evidenceDir = worktreeOf(path);
|
|
124
138
|
|
|
125
139
|
if (filesMode) {
|
|
126
140
|
const out = filesMode.map((f) => {
|
|
127
|
-
const abs = isAbsolute(f) ? f : resolve(
|
|
141
|
+
const abs = isAbsolute(f) ? f : resolve(base, f);
|
|
128
142
|
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
129
143
|
return { file: f, debt: [], note: "not found" as const };
|
|
130
144
|
}
|
|
@@ -178,7 +192,7 @@ export function cmdDebt(args: string[]): void {
|
|
|
178
192
|
if (json) {
|
|
179
193
|
console.log(
|
|
180
194
|
JSON.stringify(
|
|
181
|
-
{ ...report, promotions: findPromotions(
|
|
195
|
+
{ ...report, promotions: findPromotions(evidenceDir, report) },
|
|
182
196
|
null,
|
|
183
197
|
2,
|
|
184
198
|
),
|
|
@@ -187,7 +201,7 @@ export function cmdDebt(args: string[]): void {
|
|
|
187
201
|
}
|
|
188
202
|
console.log(formatDebt(report, showAll));
|
|
189
203
|
for (const w of loaded.warnings) console.log(`⚠ ${w}`);
|
|
190
|
-
for (const l of formatPromotions(findPromotions(
|
|
204
|
+
for (const l of formatPromotions(findPromotions(evidenceDir, report))) {
|
|
191
205
|
console.log(l);
|
|
192
206
|
}
|
|
193
207
|
}
|
package/src/debt/load.ts
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
// (<repo>/.fapony/conventions.json — via the same resolver as the mem log).
|
|
5
5
|
// Missing file = empty + no error.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
15
|
import {
|
|
10
16
|
CONVENTIONS_FILE,
|
|
11
17
|
CONVENTIONS_FILENAME,
|
|
@@ -28,6 +34,134 @@ export function resolveConventionsPath(worktree: string): string | null {
|
|
|
28
34
|
return existsSync(root) ? root : null;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
export interface DebtScope {
|
|
38
|
+
/**
|
|
39
|
+
* The dir `where` clauses and debt rel-paths are measured against — the git
|
|
40
|
+
* root inside a repo (convention `where` values are repo-relative), the
|
|
41
|
+
* resolved start dir outside one.
|
|
42
|
+
*/
|
|
43
|
+
scanRoot: string;
|
|
44
|
+
/** Conventions in scope: nearest file walking up, else the lone file under the root. */
|
|
45
|
+
loaded: LoadedConventions;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Dirs that never hold a conventions.json and are expensive to walk — same
|
|
49
|
+
// prune set as the mem-log ambiguity scan (src/core/mem-log.ts).
|
|
50
|
+
const SCOPE_SKIP = new Set([
|
|
51
|
+
"node_modules",
|
|
52
|
+
".git",
|
|
53
|
+
"dist",
|
|
54
|
+
"build",
|
|
55
|
+
"coverage",
|
|
56
|
+
"vendor",
|
|
57
|
+
"out",
|
|
58
|
+
"target",
|
|
59
|
+
".next",
|
|
60
|
+
".turbo",
|
|
61
|
+
".cache",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Every `.fapony/conventions.json` under root — bounded walk, pruned dirs skipped. */
|
|
65
|
+
function findConventionsUnder(root: string): string[] {
|
|
66
|
+
const found: string[] = [];
|
|
67
|
+
const walk = (dir: string, depth: number): void => {
|
|
68
|
+
if (depth > 6) return;
|
|
69
|
+
let entries: import("node:fs").Dirent[];
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (!e.isDirectory()) continue;
|
|
77
|
+
if (e.name === FAPONY_DIR) {
|
|
78
|
+
const f = join(dir, FAPONY_DIR, CONVENTIONS_FILENAME);
|
|
79
|
+
if (existsSync(f)) found.push(f);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (e.name.startsWith(".") || SCOPE_SKIP.has(e.name)) continue;
|
|
83
|
+
walk(join(dir, e.name), depth + 1);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(root, 0);
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single debt-scope resolver — hint (`readContextData`), the precision
|
|
92
|
+
* instrument (`computeHintImpact`), and `fapony debt` all pair the same
|
|
93
|
+
* scan root with the same conventions (bugs mucvfaxk + mucvfiv5).
|
|
94
|
+
*
|
|
95
|
+
* Each caller previously resolved differently: the hint and the instrument
|
|
96
|
+
* anchored `loadConventions` at the git root (silent whenever conventions are
|
|
97
|
+
* app-scoped), while the CLI scanned from the app dir (so repo-relative
|
|
98
|
+
* `where` values never matched and files outside the app were invisible).
|
|
99
|
+
* The scan root here is always the git root — `where` is repo-relative —
|
|
100
|
+
* and the conventions are the nearest file walking up from `fromDir`
|
|
101
|
+
* (the app case); when the walk misses — a file outside any app dir, or the
|
|
102
|
+
* repo root itself — the lone `conventions.json` under the root wins.
|
|
103
|
+
* Two or more files under the root stay ambiguous (null, don't guess).
|
|
104
|
+
*/
|
|
105
|
+
export function resolveDebtScope(fromDir: string): DebtScope {
|
|
106
|
+
let start = resolve(fromDir);
|
|
107
|
+
try {
|
|
108
|
+
if (!statSync(start).isDirectory()) start = dirname(start);
|
|
109
|
+
} catch {
|
|
110
|
+
// nonexistent path — keep the lexical form, resolution below still applies
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
// Physical path so the walk, the boundary check, and scanRoot compare
|
|
114
|
+
// like with like (/var → /private/var on macOS).
|
|
115
|
+
start = realpathSync(start);
|
|
116
|
+
} catch {
|
|
117
|
+
// vanished mid-call — keep the lexical form
|
|
118
|
+
}
|
|
119
|
+
// `git rev-parse` returns a physical path (/var → /private/var on macOS),
|
|
120
|
+
// so the boundary check compares realpaths, same as worktreeOf in cli.ts.
|
|
121
|
+
const physical = (d: string): string => {
|
|
122
|
+
try {
|
|
123
|
+
return realpathSync(d);
|
|
124
|
+
} catch {
|
|
125
|
+
return d;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
let gitRoot: string | null = null;
|
|
129
|
+
try {
|
|
130
|
+
const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
131
|
+
cwd: start,
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "pipe",
|
|
134
|
+
});
|
|
135
|
+
if (p.exitCode === 0) gitRoot = p.stdout.toString().trim() || null;
|
|
136
|
+
} catch {
|
|
137
|
+
// not a repo — start is all we have
|
|
138
|
+
}
|
|
139
|
+
const scanRoot = gitRoot ? physical(gitRoot) : start;
|
|
140
|
+
const boundary = gitRoot ? physical(gitRoot) : null;
|
|
141
|
+
|
|
142
|
+
// Nearest conventions.json walking up (the app case) — bounded by the root.
|
|
143
|
+
let dir = start;
|
|
144
|
+
while (true) {
|
|
145
|
+
if (existsSync(join(dir, CONVENTIONS_FILE))) {
|
|
146
|
+
return { scanRoot, loaded: loadConventions(dir) };
|
|
147
|
+
}
|
|
148
|
+
if (boundary && physical(dir) === boundary) break;
|
|
149
|
+
const parent = dirname(dir);
|
|
150
|
+
if (parent === dir) break;
|
|
151
|
+
dir = parent;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sibling-app case: the file sits outside any app dir (e.g. packages/*),
|
|
155
|
+
// but the repo holds exactly one conventions file — use it.
|
|
156
|
+
if (gitRoot) {
|
|
157
|
+
const found = findConventionsUnder(scanRoot);
|
|
158
|
+
if (found.length === 1) {
|
|
159
|
+
return { scanRoot, loaded: loadConventions(dirname(dirname(found[0]))) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { scanRoot, loaded: loadConventions(scanRoot) };
|
|
163
|
+
}
|
|
164
|
+
|
|
31
165
|
function asString(v: unknown): string | undefined {
|
|
32
166
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
33
167
|
}
|