fapony 0.3.3 → 0.3.5
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 +7 -1
- package/fapony.ts +3 -116
- package/package.json +5 -6
- package/skill/move-to-done/SKILL.md +3 -2
- package/src/adapters/cli.ts +128 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +80 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +434 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
- package/src/analyze.ts +1 -1
- package/src/commands.ts +180 -0
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +2 -2
- package/src/gates.ts +1 -1
- package/src/hook.ts +70 -1424
- package/src/init-mem.ts +1 -1
- package/src/init.ts +1 -1
- package/src/install/claude.ts +17 -0
- package/src/install/opencode.ts +114 -0
- package/src/install.ts +12 -5
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +197 -42
- package/src/mem/commands/read.ts +53 -12
- package/src/mem/index.ts +60 -1
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +26 -388
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/seed/plan-seed.ts +126 -32
- package/src/seed/primitives.ts +1 -7
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +3 -3
- package/src/telemetry.ts +2 -2
- package/src/update.ts +1 -1
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -93
- package/src/web/html.ts +2 -33
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/mcp/types.ts +0 -54
- package/src/test.ts +0 -2
- /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
- /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/core/hint-log.ts — hint-fire log path + write + types (pure, no feature imports)
|
|
2
|
+
//
|
|
3
|
+
// Extracted from src/hook.ts (PLAN-lib-layer chunk 2a) to break the
|
|
4
|
+
// digest→hook coupling. computeHintImpact stays in hook.ts because it
|
|
5
|
+
// imports debtForFile (feature layer).
|
|
6
|
+
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const HINT_LOG_DIR = "hint-log";
|
|
12
|
+
|
|
13
|
+
/** Stable filename key from an absolute worktree path. */
|
|
14
|
+
export function worktreeKey(worktree: string): string {
|
|
15
|
+
return worktree.replace(/^\/+/, "").replace(/\//g, "--");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Directory holding one hint-fire log file per worktree. */
|
|
19
|
+
export function hintLogDir(): string {
|
|
20
|
+
const base =
|
|
21
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
22
|
+
return join(base, HINT_LOG_DIR);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Absolute path of a worktree's hint-fire log — may not exist. */
|
|
26
|
+
export function hintLogPath(worktree: string): string {
|
|
27
|
+
return join(hintLogDir(), `${worktreeKey(worktree)}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HintFireRow {
|
|
31
|
+
ts: string;
|
|
32
|
+
worktree: string;
|
|
33
|
+
surface: "read" | "debt" | "mem" | "commit" | "edit";
|
|
34
|
+
file: string | null;
|
|
35
|
+
count: number;
|
|
36
|
+
ids?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Append a hint-fire log row. Best-effort: never throws, never blocks.
|
|
41
|
+
* Uses $FAPONY_STATE_DIR when set (tests, CI), otherwise ~/.config/fapony.
|
|
42
|
+
*/
|
|
43
|
+
export function recordHintFire(row: HintFireRow): void {
|
|
44
|
+
try {
|
|
45
|
+
const dir = hintLogDir();
|
|
46
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
47
|
+
appendFileSync(
|
|
48
|
+
hintLogPath(row.worktree),
|
|
49
|
+
`${JSON.stringify(row)}\n`,
|
|
50
|
+
"utf-8",
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
// best-effort — swallow
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface HintImpact {
|
|
58
|
+
fired: number;
|
|
59
|
+
by_surface: {
|
|
60
|
+
read: number;
|
|
61
|
+
debt: number;
|
|
62
|
+
mem: number;
|
|
63
|
+
commit: number;
|
|
64
|
+
edit: number;
|
|
65
|
+
};
|
|
66
|
+
debt: { shown: number; resolved: number; unknown: number };
|
|
67
|
+
window: string | null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/core/hook-helpers.ts — pure helpers shared by hook adapters
|
|
2
|
+
//
|
|
3
|
+
// Extracted from src/hook.ts (PLAN-lib-layer chunk 3). These have zero
|
|
4
|
+
// feature imports and are reused by stop, read-hint, edit-hint, and
|
|
5
|
+
// session-start adapters.
|
|
6
|
+
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** UTC 'YYYY-MM-DD HH:MM:SS' — the format events.ts is written in. */
|
|
10
|
+
export function utcStamp(d: Date): string {
|
|
11
|
+
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse either timestamp shape this repo produces: mem rows are ISO
|
|
16
|
+
* (`new Date().toISOString()`), session start is utcStamp
|
|
17
|
+
* ('YYYY-MM-DD HH:MM:SS', UTC). Never compare them as strings — 'T' > ' '
|
|
18
|
+
* makes any same-date mem row read as "newer than session start".
|
|
19
|
+
*/
|
|
20
|
+
export function hookTsMs(ts: string): number {
|
|
21
|
+
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)
|
|
22
|
+
? `${ts.replace(" ", "T")}Z`
|
|
23
|
+
: ts;
|
|
24
|
+
return new Date(iso).getTime();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Filename key for a session — basename of a transcript path or a raw id. */
|
|
28
|
+
export function sessionKey(session: string): string {
|
|
29
|
+
const base = basename(session).replace(/\.[^.]+$/, "");
|
|
30
|
+
return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
|
|
31
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// src/core/mem-log.ts — mem-log reader: directory discovery + JSONL parsing (pure, no feature imports)
|
|
2
|
+
//
|
|
3
|
+
// Extracted from src/memory.ts (PLAN-lib-layer chunk 2b). Shell adapter
|
|
4
|
+
// helpers (closeMemory, kickoffMemory, claimMemory) stay in memory.ts.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_FILENAME,
|
|
10
|
+
DEFAULT_MEM_DIR,
|
|
11
|
+
FAPONY_DIR,
|
|
12
|
+
loadConfig,
|
|
13
|
+
} from "./config.js";
|
|
14
|
+
|
|
15
|
+
export interface MemRow {
|
|
16
|
+
ts: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
text: string;
|
|
20
|
+
spec?: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
ref?: string;
|
|
23
|
+
/** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
|
|
24
|
+
files?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RawMemRow {
|
|
28
|
+
ts?: string;
|
|
29
|
+
agent?: string;
|
|
30
|
+
kind?: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
spec?: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
ref?: string;
|
|
35
|
+
files?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A directory is a mem dir only when it holds a `log.jsonl` or a named
|
|
40
|
+
* `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
|
|
41
|
+
* A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
|
|
42
|
+
* otherwise it would shadow an ancestor that holds the real log (SPEC §1).
|
|
43
|
+
*/
|
|
44
|
+
function hasMemLogs(dir: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return readdirSync(dir).some(
|
|
47
|
+
(f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Physical path (symlinks resolved) so start and repo root compare like with
|
|
55
|
+
* like; falls back to a lexical resolve when the path does not exist yet. */
|
|
56
|
+
function physical(p: string): string {
|
|
57
|
+
try {
|
|
58
|
+
return realpathSync(p);
|
|
59
|
+
} catch {
|
|
60
|
+
return resolve(p);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
|
|
65
|
+
function gitRootOf(fromDir: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
const { execSync } =
|
|
68
|
+
require("node:child_process") as typeof import("node:child_process");
|
|
69
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
70
|
+
cwd: fromDir,
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
}).trim();
|
|
74
|
+
return root || null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The lexical ancestor of `fromDir` that is the git repo root, or null outside
|
|
82
|
+
* a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
|
|
83
|
+
* var on macOS), so the walk compares `physical(dir)` against it and returns the
|
|
84
|
+
* path in the caller's own lexical form — the returned dir must match what the
|
|
85
|
+
* caller passed in, not a canonicalized stranger.
|
|
86
|
+
*/
|
|
87
|
+
function repoRootOf(fromDir: string): string | null {
|
|
88
|
+
const start = resolve(fromDir);
|
|
89
|
+
const gitRoot = gitRootOf(start);
|
|
90
|
+
if (!gitRoot) return null;
|
|
91
|
+
const physicalRoot = physical(gitRoot);
|
|
92
|
+
let dir = start;
|
|
93
|
+
while (true) {
|
|
94
|
+
if (physical(dir) === physicalRoot) return dir;
|
|
95
|
+
const parent = dirname(dir);
|
|
96
|
+
if (parent === dir) return null;
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
|
|
103
|
+
*
|
|
104
|
+
* The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
|
|
105
|
+
* checkout is never ours. Outside a repo it stops at the filesystem root.
|
|
106
|
+
* `acceptEmpty` is for the default-wiring check, which only needs the dir the
|
|
107
|
+
* writer will use (log or not); the resolver itself requires a real log.
|
|
108
|
+
*/
|
|
109
|
+
export function walkUpForMemDir(
|
|
110
|
+
fromDir: string,
|
|
111
|
+
acceptEmpty: boolean,
|
|
112
|
+
): string | null {
|
|
113
|
+
const start = resolve(fromDir);
|
|
114
|
+
const boundary = repoRootOf(start) ?? "/";
|
|
115
|
+
let dir = start;
|
|
116
|
+
while (true) {
|
|
117
|
+
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
118
|
+
if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
if (dir === boundary) break;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break;
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface MemDirResult {
|
|
130
|
+
dir: string | null;
|
|
131
|
+
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
132
|
+
candidates?: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Dirs that never hold a project mem log and are expensive to walk.
|
|
136
|
+
const SCAN_SKIP = new Set([
|
|
137
|
+
"node_modules",
|
|
138
|
+
".git",
|
|
139
|
+
"dist",
|
|
140
|
+
"build",
|
|
141
|
+
"coverage",
|
|
142
|
+
"vendor",
|
|
143
|
+
"out",
|
|
144
|
+
"target",
|
|
145
|
+
".next",
|
|
146
|
+
".turbo",
|
|
147
|
+
".cache",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
|
|
152
|
+
* shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
|
|
153
|
+
* fail example): if two or more app-scoped logs exist and nothing at/above cwd
|
|
154
|
+
* holds one, the caller must refuse rather than start a third log at the root.
|
|
155
|
+
*/
|
|
156
|
+
function findMemDirsUnder(root: string): string[] {
|
|
157
|
+
const found: string[] = [];
|
|
158
|
+
const walk = (dir: string, depth: number): void => {
|
|
159
|
+
if (depth > 5) return;
|
|
160
|
+
let entries: import("node:fs").Dirent[];
|
|
161
|
+
try {
|
|
162
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
if (!e.isDirectory()) continue;
|
|
168
|
+
if (e.name === FAPONY_DIR) {
|
|
169
|
+
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
170
|
+
if (hasMemLogs(candidate)) found.push(candidate);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
|
|
174
|
+
walk(join(dir, e.name), depth + 1);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(root, 0);
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The single resolver: returns the mem dir and which step won.
|
|
183
|
+
*
|
|
184
|
+
* Resolution order (SPEC §1):
|
|
185
|
+
* 1. `--mem-dir <path>` (wins over everything)
|
|
186
|
+
* 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
|
|
187
|
+
* 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
|
|
188
|
+
* 4. `<repo root>/.fapony/.memory/`
|
|
189
|
+
*/
|
|
190
|
+
function resolveMemDirFrom(
|
|
191
|
+
fromDir: string,
|
|
192
|
+
explicitOverride?: string,
|
|
193
|
+
): MemDirResult {
|
|
194
|
+
// Step 1: --mem-dir flag
|
|
195
|
+
if (explicitOverride) {
|
|
196
|
+
return {
|
|
197
|
+
dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
|
|
198
|
+
step: "flag",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const cwd = resolve(fromDir);
|
|
203
|
+
const root = repoRootOf(cwd);
|
|
204
|
+
|
|
205
|
+
// Step 2: paths.memDir in the repo-root config
|
|
206
|
+
const configDir = root ?? cwd;
|
|
207
|
+
try {
|
|
208
|
+
const configPath = join(configDir, CONFIG_FILENAME);
|
|
209
|
+
if (existsSync(configPath)) {
|
|
210
|
+
const config = loadConfig(configPath);
|
|
211
|
+
if (config?.paths?.memDir) {
|
|
212
|
+
const absCfg = isAbsolute(config.paths.memDir)
|
|
213
|
+
? config.paths.memDir
|
|
214
|
+
: join(configDir, config.paths.memDir);
|
|
215
|
+
if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// no config or unreadable — continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Step 3: walk up from cwd, only a dir with a real log counts
|
|
223
|
+
const walked = walkUpForMemDir(cwd, false);
|
|
224
|
+
if (walked) return { dir: walked, step: "walk-up" };
|
|
225
|
+
|
|
226
|
+
// Guard (SPEC §1 fail example)
|
|
227
|
+
const candidates = root ? findMemDirsUnder(root) : [];
|
|
228
|
+
if (candidates.length >= 2) {
|
|
229
|
+
return { dir: null, step: "ambiguous", candidates };
|
|
230
|
+
}
|
|
231
|
+
const outOfScope = candidates.length ? { candidates } : {};
|
|
232
|
+
|
|
233
|
+
// Step 4: <repo root>/.fapony/.memory/
|
|
234
|
+
if (root) {
|
|
235
|
+
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
236
|
+
if (existsSync(rootDir)) {
|
|
237
|
+
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { dir: null, step: "none", ...outOfScope };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resolveMemDir(
|
|
245
|
+
worktree?: string,
|
|
246
|
+
explicitOverride?: string,
|
|
247
|
+
): string | null {
|
|
248
|
+
return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Resolve and report which step won — for `fapony mem where`. */
|
|
252
|
+
export function whereMemDir(
|
|
253
|
+
fromDir?: string,
|
|
254
|
+
explicitOverride?: string,
|
|
255
|
+
): MemDirResult {
|
|
256
|
+
return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read every `log*.jsonl` row under the worktree's memory dir, newest first.
|
|
261
|
+
* `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
|
|
262
|
+
* Malformed/unreadable rows are counted, never thrown — a corrupt line must
|
|
263
|
+
* not take down the caller.
|
|
264
|
+
*/
|
|
265
|
+
export function readMemLog(
|
|
266
|
+
worktree: string,
|
|
267
|
+
sinceIso?: string,
|
|
268
|
+
): { rows: MemRow[]; skipped: number; filesFound: number } {
|
|
269
|
+
const dir = resolveMemDir(worktree);
|
|
270
|
+
if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
|
|
271
|
+
|
|
272
|
+
const isLogFile = (f: string): boolean =>
|
|
273
|
+
f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
|
|
274
|
+
|
|
275
|
+
let files: string[];
|
|
276
|
+
try {
|
|
277
|
+
files = readdirSync(dir)
|
|
278
|
+
.filter(isLogFile)
|
|
279
|
+
.sort()
|
|
280
|
+
.map((f) => join(dir, f));
|
|
281
|
+
} catch {
|
|
282
|
+
return { rows: [], skipped: 0, filesFound: 0 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (files.length === 0) return { rows: [], skipped: 0, filesFound: 0 };
|
|
286
|
+
|
|
287
|
+
let skipped = 0;
|
|
288
|
+
const all: MemRow[] = [];
|
|
289
|
+
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
let raw: string;
|
|
292
|
+
try {
|
|
293
|
+
raw = readFileSync(file, "utf-8");
|
|
294
|
+
} catch {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
298
|
+
for (const line of lines) {
|
|
299
|
+
let parsed: RawMemRow;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(line) as RawMemRow;
|
|
302
|
+
} catch {
|
|
303
|
+
skipped++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!parsed.ts || !parsed.kind) {
|
|
307
|
+
skipped++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (sinceIso && parsed.ts < sinceIso) continue;
|
|
311
|
+
all.push({
|
|
312
|
+
ts: parsed.ts,
|
|
313
|
+
agent: parsed.agent ?? "unknown",
|
|
314
|
+
kind: parsed.kind,
|
|
315
|
+
text: parsed.text ?? "",
|
|
316
|
+
spec: parsed.spec,
|
|
317
|
+
id: parsed.id,
|
|
318
|
+
ref: parsed.ref,
|
|
319
|
+
...(Array.isArray(parsed.files)
|
|
320
|
+
? { files: parsed.files.filter((f) => typeof f === "string") }
|
|
321
|
+
: {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
all.sort((a, b) => b.ts.localeCompare(a.ts)); // newest first
|
|
327
|
+
return { rows: all, skipped, filesFound: files.length };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Decisions for the pre-edit context summary — newest first, capped at `limit`.
|
|
332
|
+
*/
|
|
333
|
+
export function readRecentMemDecisions(
|
|
334
|
+
worktree: string,
|
|
335
|
+
limit: number,
|
|
336
|
+
keywords?: string[],
|
|
337
|
+
): MemRow[] {
|
|
338
|
+
let decisions: MemRow[];
|
|
339
|
+
try {
|
|
340
|
+
decisions = readMemLog(worktree).rows.filter((r) => r.kind === "decision");
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
if (decisions.length === 0) return [];
|
|
345
|
+
|
|
346
|
+
const kws = (keywords ?? [])
|
|
347
|
+
.map((k) => k.toLowerCase())
|
|
348
|
+
.filter((k) => k.length > 0);
|
|
349
|
+
if (kws.length > 0) {
|
|
350
|
+
const hits = decisions.filter((r) => {
|
|
351
|
+
const hay = `${r.text}\n${r.spec ?? ""}`.toLowerCase();
|
|
352
|
+
return kws.some((k) => hay.includes(k));
|
|
353
|
+
});
|
|
354
|
+
if (hits.length > 0) return hits.slice(0, limit);
|
|
355
|
+
}
|
|
356
|
+
return decisions.slice(0, limit);
|
|
357
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/parse.ts — grades, scores, and gate-event JSON parsing.
|
|
2
|
+
// Marker-text parsers (parseGateVerdict/parsePlanUpdate) were removed with the
|
|
3
|
+
// CLI loop: nothing produces VERDICT:/NEXT-PROMPT/FILE_DONE stdout anymore —
|
|
4
|
+
// gate events are JSON, read via parseGateEventData.
|
|
5
|
+
|
|
6
|
+
/** Locked grade vocabulary — additive-only (append, never rename/remove). */
|
|
7
|
+
export type VerdictGrade =
|
|
8
|
+
| "pass-excellent"
|
|
9
|
+
| "pass-good"
|
|
10
|
+
| "pass-adequate"
|
|
11
|
+
| "pass"
|
|
12
|
+
| "fail"
|
|
13
|
+
| "uncertain";
|
|
14
|
+
|
|
15
|
+
export const VERDICT_GRADES: ReadonlySet<string> = new Set<VerdictGrade>([
|
|
16
|
+
"pass-excellent",
|
|
17
|
+
"pass-good",
|
|
18
|
+
"pass-adequate",
|
|
19
|
+
"pass",
|
|
20
|
+
"fail",
|
|
21
|
+
"uncertain",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** True when verdict belongs to the pass family (any pass-* variant). */
|
|
25
|
+
export function isPassFamily(v: string): v is VerdictGrade {
|
|
26
|
+
return v.startsWith("pass");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locked qualityScore mapping — read-time only (never written to events).
|
|
31
|
+
* Values from SPEC-verdict-protocol §qualityScore (additive-only, never change).
|
|
32
|
+
*/
|
|
33
|
+
const SCORE_MAP: Record<VerdictGrade, number> = {
|
|
34
|
+
"pass-excellent": 5,
|
|
35
|
+
"pass-good": 4,
|
|
36
|
+
"pass-adequate": 3,
|
|
37
|
+
pass: 3,
|
|
38
|
+
fail: 0,
|
|
39
|
+
uncertain: 1,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function qualityScore(grade: VerdictGrade): number {
|
|
43
|
+
return SCORE_MAP[grade] ?? 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GateVerdict {
|
|
47
|
+
verdict: VerdictGrade;
|
|
48
|
+
note: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read a verdict back from a stored gate event (`kind='gate'`).
|
|
53
|
+
* Gate events are JSON (`{verdict, note, round}` from gateOnce) — NOT
|
|
54
|
+
* reviewer stdout. Returns null for missing/unparseable data or unknown grades.
|
|
55
|
+
*/
|
|
56
|
+
export function parseGateEventData(data: string | null): GateVerdict | null {
|
|
57
|
+
if (!data) return null;
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(data);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
65
|
+
const { verdict, note } = parsed as { verdict?: unknown; note?: unknown };
|
|
66
|
+
if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) return null;
|
|
67
|
+
return {
|
|
68
|
+
verdict: verdict as VerdictGrade,
|
|
69
|
+
note: typeof note === "string" ? note : "",
|
|
70
|
+
};
|
|
71
|
+
}
|