fapony 0.3.0 → 0.3.3
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 +87 -65
- package/fapony.ts +2 -2
- package/package.json +5 -4
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +27 -58
- package/src/analyze.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +210 -123
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +2 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +53 -26
- package/src/install.ts +12 -3
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +0 -74
- package/src/mcp/transport.ts +4 -39
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
package/src/mem/index.ts
CHANGED
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
// Now an export function called by fapony.ts, not a standalone script.
|
|
5
5
|
|
|
6
6
|
import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
|
|
7
|
-
import {
|
|
8
|
-
cmdDone,
|
|
9
|
-
cmdFind,
|
|
10
|
-
cmdKickoff,
|
|
11
|
-
cmdNow,
|
|
12
|
-
cmdStale,
|
|
13
|
-
} from "./commands/read.js";
|
|
7
|
+
import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
|
|
14
8
|
import { cmdRotate } from "./commands/rotate.js";
|
|
15
9
|
import { cmdWhere } from "./commands/where.js";
|
|
16
10
|
import {
|
|
@@ -30,7 +24,7 @@ subcommands:
|
|
|
30
24
|
add <kind> "<text>" --files f1,f2 [spec.md]
|
|
31
25
|
close <id> "<msg>" close a bug
|
|
32
26
|
find "<text>" substring-search every row
|
|
33
|
-
|
|
27
|
+
done | stale views
|
|
34
28
|
claim <id> | release <id> | synced bookkeeping
|
|
35
29
|
plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
|
|
36
30
|
plan-check <plan.md> validate a plan's frontmatter/sections
|
|
@@ -62,9 +56,6 @@ example: fapony mem close mt14 "fixed in a2c6beb"`,
|
|
|
62
56
|
find: `usage: fapony mem find "<text>"
|
|
63
57
|
substring search over every row's text/spec/ref — all kinds, no default filter
|
|
64
58
|
example: fapony mem find "usage-web"`,
|
|
65
|
-
now: `usage: fapony mem now
|
|
66
|
-
next + bug + hold still open (decision/note are search-only — use find)
|
|
67
|
-
example: fapony mem now`,
|
|
68
59
|
done: `usage: fapony mem done
|
|
69
60
|
closed rows with their tombstone message
|
|
70
61
|
example: fapony mem done`,
|
|
@@ -138,7 +129,7 @@ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
|
138
129
|
} else if (cmd === "rotate") {
|
|
139
130
|
cmdRotate(rest);
|
|
140
131
|
} else {
|
|
141
|
-
//
|
|
142
|
-
|
|
132
|
+
// bare `fapony mem` → kickoff (ranked session overview)
|
|
133
|
+
cmdKickoff(rest);
|
|
143
134
|
}
|
|
144
135
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
|
|
1
|
+
// src/seed/plan-seed.ts — `fapony plan-seed <name> [--spec] [--scope <path>]...`
|
|
2
2
|
//
|
|
3
3
|
// Writes PLAN + SPEC straight into planDir/specDir. What it pre-fills is the
|
|
4
4
|
// structure (frontmatter, the 8 sections, prior art, ledger context) — the
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Composes existing producers — no new parsing, no new table, no MCP tool.
|
|
27
27
|
|
|
28
|
-
import { execSync } from "node:child_process";
|
|
29
28
|
import {
|
|
30
29
|
existsSync,
|
|
31
30
|
mkdirSync,
|
|
@@ -35,15 +34,16 @@ import {
|
|
|
35
34
|
writeFileSync,
|
|
36
35
|
} from "node:fs";
|
|
37
36
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
38
|
-
import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "
|
|
39
|
-
import { computeModelFit } from "
|
|
40
|
-
import { doneDir, planDir, specDir } from "
|
|
41
|
-
import { CONFIG_FILENAME } from "
|
|
42
|
-
import { loadConfig } from "
|
|
43
|
-
import type { Config } from "
|
|
44
|
-
import { extractExports } from "
|
|
45
|
-
import { readRecentMemDecisions } from "
|
|
46
|
-
import { getStatsData } from "
|
|
37
|
+
import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "../analyze.js";
|
|
38
|
+
import { computeModelFit } from "../context/projectHealth.js";
|
|
39
|
+
import { doneDir, planDir, specDir } from "../db/getters.js";
|
|
40
|
+
import { CONFIG_FILENAME } from "../db/index.js";
|
|
41
|
+
import { loadConfig } from "../db/load.js";
|
|
42
|
+
import type { Config } from "../db/types.js";
|
|
43
|
+
import { extractExports } from "../map.js";
|
|
44
|
+
import { readRecentMemDecisions } from "../memory.js";
|
|
45
|
+
import { getStatsData } from "../stats/data.js";
|
|
46
|
+
import { capLines, execGit, SIG_MAX } from "./primitives.js";
|
|
47
47
|
|
|
48
48
|
// One chunk = one module's signatures — past ~40 lines a module is its own
|
|
49
49
|
// reading task, and the whole-SPEC cap below does the final trim.
|
|
@@ -56,7 +56,6 @@ const SCOPE_WARN_FILES = 300;
|
|
|
56
56
|
// Shipped plans/specs that already touched this scope. Capped low on purpose:
|
|
57
57
|
// this is a "go read that first" pointer, not a bibliography.
|
|
58
58
|
const MAX_PRIOR_ART = 5;
|
|
59
|
-
const SIG_MAX = 90;
|
|
60
59
|
// Anchor-safe slug: lowercase, non-alphanumerics → dash.
|
|
61
60
|
const slug = (s: string): string =>
|
|
62
61
|
s
|
|
@@ -224,7 +223,7 @@ next step in the same session is what rule 9 forbids.
|
|
|
224
223
|
1. _(agent fills in — each step must be verifiable)_
|
|
225
224
|
|
|
226
225
|
**Closing a step:** tick its TL;DR box with the sha · \`git commit\` this step's
|
|
227
|
-
files only ·
|
|
226
|
+
files only · then hand off:
|
|
228
227
|
|
|
229
228
|
\`\`\`bash
|
|
230
229
|
fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}
|
|
@@ -257,16 +256,6 @@ interface Chunk {
|
|
|
257
256
|
body: string;
|
|
258
257
|
}
|
|
259
258
|
|
|
260
|
-
// review-seed's cap shape: keep the head, always say how much was cut — a
|
|
261
|
-
// silent cut is indistinguishable from "that was everything".
|
|
262
|
-
function capLines(lines: string[], cap: number, what: string): string[] {
|
|
263
|
-
if (lines.length <= cap) return lines;
|
|
264
|
-
const rest = lines.length - (cap - 1);
|
|
265
|
-
const kept = lines.slice(0, cap - 1);
|
|
266
|
-
kept.push(`… +${rest} more ${what}`);
|
|
267
|
-
return kept;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
259
|
function capChunk(c: Chunk): Chunk {
|
|
271
260
|
return {
|
|
272
261
|
...c,
|
|
@@ -530,15 +519,8 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
530
519
|
// state.db uses (git rev-parse --show-toplevel). Running from a subdir
|
|
531
520
|
// would otherwise mismatch: stats return empty, Context (fapony) always
|
|
532
521
|
// prints "(not enough graded history yet)".
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
worktree = execSync("git rev-parse --show-toplevel", {
|
|
536
|
-
encoding: "utf-8",
|
|
537
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
538
|
-
}).trim();
|
|
539
|
-
} catch {
|
|
540
|
-
worktree = cwd;
|
|
541
|
-
}
|
|
522
|
+
const root = execGit("git rev-parse --show-toplevel", cwd);
|
|
523
|
+
const worktree = root.ok ? root.output.split("\n")[0] : cwd;
|
|
542
524
|
|
|
543
525
|
// Scope: explicit paths win; default is the cwd. Resolved absolutes,
|
|
544
526
|
// deduped — the same path twice is one scope. Nested roots are pruned:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/seed/primitives.ts — shared helpers for plan-seed + review-seed
|
|
2
|
+
//
|
|
3
|
+
// Git execution, output capping, signature formatting — the pieces both
|
|
4
|
+
// commands need. §0 rule: add-only — never remove or rename exported symbols.
|
|
5
|
+
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// --- Error class ---
|
|
9
|
+
|
|
10
|
+
export class SeedError extends Error {}
|
|
11
|
+
|
|
12
|
+
// --- Git helpers ---
|
|
13
|
+
|
|
14
|
+
export function execGit(
|
|
15
|
+
cmd: string,
|
|
16
|
+
cwd: string,
|
|
17
|
+
): { ok: boolean; output: string; error?: string } {
|
|
18
|
+
try {
|
|
19
|
+
const output = execSync(cmd, {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
23
|
+
timeout: 15_000,
|
|
24
|
+
});
|
|
25
|
+
return { ok: true, output: output.trim() };
|
|
26
|
+
} catch (e: unknown) {
|
|
27
|
+
const err = e as { stderr?: string; message?: string };
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
output: "",
|
|
31
|
+
error: (err.stderr ?? err.message ?? "").trim(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function gitOk(r: { ok: boolean; error?: string }, cmd: string): void {
|
|
37
|
+
if (!r.ok)
|
|
38
|
+
throw new SeedError(`git failed: ${cmd}\n${r.error ?? "unknown error"}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Values interpolated into a git command line must be plain refs/paths —
|
|
42
|
+
// blocks shell metacharacters before execSync ever sees them.
|
|
43
|
+
const GIT_VALUE_RE = /^[A-Za-z0-9._/{}^~+-]+$/;
|
|
44
|
+
|
|
45
|
+
export function gitValue(kind: string, value: string): string {
|
|
46
|
+
if (!GIT_VALUE_RE.test(value)) {
|
|
47
|
+
throw new SeedError(`invalid ${kind}: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Output capping ---
|
|
53
|
+
|
|
54
|
+
// Keep the head, always say how much was cut — a silent cut is
|
|
55
|
+
// indistinguishable from "that was everything".
|
|
56
|
+
export function capLines(lines: string[], cap: number, what: string): string[] {
|
|
57
|
+
if (lines.length <= cap) return lines;
|
|
58
|
+
const rest = lines.length - (cap - 1);
|
|
59
|
+
const kept = lines.slice(0, cap - 1);
|
|
60
|
+
kept.push(`… +${rest} more ${what}`);
|
|
61
|
+
return kept;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Signature formatting ---
|
|
65
|
+
|
|
66
|
+
export const SIG_MAX = 90;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/review-seed.ts — `fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>]`
|
|
1
|
+
// src/seed/review-seed.ts — `fapony review-seed [--staged|--commit <sha>|--range <a...b>|--files f1,f2,dir|--plan <PLAN.md>]`
|
|
2
2
|
//
|
|
3
3
|
// Seeds a code review with the deterministic facts of the scope the agent
|
|
4
4
|
// asked about: which files changed (per the exact git expression, echoed),
|
|
@@ -12,14 +12,13 @@
|
|
|
12
12
|
// cite rule 5 "never write into a target worktree", dropped 2026-09-17 because
|
|
13
13
|
// four commands broke it; being read-only was always a property of this
|
|
14
14
|
// command, never of that rule. Facts only: nothing here says broken/fixed
|
|
15
|
-
// — judgment lives in the reviewer and
|
|
15
|
+
// — judgment lives in the reviewer and mem log, never in
|
|
16
16
|
// this output. Deterministic: same input, same bytes, no LLM.
|
|
17
17
|
//
|
|
18
18
|
// Composes existing producers — buildGraph (analyze.ts) for importers/untested,
|
|
19
19
|
// extractExports (map.ts) for signatures. One flag = one declared git call;
|
|
20
20
|
// no magic parsing.
|
|
21
21
|
|
|
22
|
-
import { execSync } from "node:child_process";
|
|
23
22
|
import type { Stats } from "node:fs";
|
|
24
23
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
25
24
|
import { join } from "node:path";
|
|
@@ -30,9 +29,10 @@ import {
|
|
|
30
29
|
isTestedThroughBarrels,
|
|
31
30
|
isTestFile,
|
|
32
31
|
SCAN_EXTS,
|
|
33
|
-
} from "
|
|
34
|
-
import { extractBody, extractExports } from "
|
|
35
|
-
import { assertSafe } from "
|
|
32
|
+
} from "../analyze.js";
|
|
33
|
+
import { extractBody, extractExports } from "../map.js";
|
|
34
|
+
import { assertSafe } from "../safety.js";
|
|
35
|
+
import { execGit, gitOk, gitValue, SeedError, SIG_MAX } from "./primitives.js";
|
|
36
36
|
|
|
37
37
|
const WRAP_WIDTH = 88;
|
|
38
38
|
// The changed list is the review's scope boundary, not context: a file hidden
|
|
@@ -58,7 +58,6 @@ const LOOKUP_OUTPUT_CAP = 120;
|
|
|
58
58
|
// files is already past "this component area" into "the whole tree" — cut
|
|
59
59
|
// there and say so, a folder-shaped wall is not an answer either.
|
|
60
60
|
// Signature text cap per symbol (same trim as map.ts's file view).
|
|
61
|
-
const SIG_MAX = 90;
|
|
62
61
|
const DISCLAIMER =
|
|
63
62
|
"static graph only — seed is where to enter, not what is verified";
|
|
64
63
|
const USAGE =
|
|
@@ -70,52 +69,6 @@ const MAX_BODY_LINES = 80;
|
|
|
70
69
|
const MAX_CALLER_FILES = 12;
|
|
71
70
|
const MAX_CALLER_HITS = 20;
|
|
72
71
|
|
|
73
|
-
export class SeedError extends Error {}
|
|
74
|
-
|
|
75
|
-
// --- Git helpers (same shape as collect.ts execGitSafe) ---
|
|
76
|
-
|
|
77
|
-
function execGit(
|
|
78
|
-
cmd: string,
|
|
79
|
-
cwd: string,
|
|
80
|
-
): { ok: boolean; output: string; error?: string } {
|
|
81
|
-
try {
|
|
82
|
-
const output = execSync(cmd, {
|
|
83
|
-
cwd,
|
|
84
|
-
encoding: "utf-8",
|
|
85
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
-
timeout: 15_000,
|
|
87
|
-
});
|
|
88
|
-
return { ok: true, output: output.trim() };
|
|
89
|
-
} catch (e: unknown) {
|
|
90
|
-
const err = e as { stderr?: string; message?: string };
|
|
91
|
-
return {
|
|
92
|
-
ok: false,
|
|
93
|
-
output: "",
|
|
94
|
-
error: (err.stderr ?? err.message ?? "").trim(),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// A scope's primary git call failing must never masquerade as "nothing
|
|
100
|
-
// changed" — surface it. merge-base failures are tolerated (label fallback).
|
|
101
|
-
function gitOk(r: { ok: boolean; error?: string }, cmd: string): void {
|
|
102
|
-
if (!r.ok)
|
|
103
|
-
throw new SeedError(
|
|
104
|
-
`review-seed: git failed: ${cmd}\n${r.error ?? "unknown error"}`,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Values interpolated into a git command line must be plain refs/paths —
|
|
109
|
-
// blocks shell metacharacters before execSync ever sees them.
|
|
110
|
-
const GIT_VALUE_RE = /^[A-Za-z0-9._/{}^~+-]+$/;
|
|
111
|
-
|
|
112
|
-
function gitValue(kind: string, value: string): string {
|
|
113
|
-
if (!GIT_VALUE_RE.test(value)) {
|
|
114
|
-
throw new SeedError(`review-seed: invalid ${kind}: ${value}`);
|
|
115
|
-
}
|
|
116
|
-
return value;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
72
|
// --- Scope flags: exactly one source of scope ---
|
|
120
73
|
|
|
121
74
|
type Scope =
|
|
@@ -233,7 +186,7 @@ function parseScope(args: string[]): Scope {
|
|
|
233
186
|
|
|
234
187
|
// --- Scope resolution: one flag = one declared git call ---
|
|
235
188
|
|
|
236
|
-
interface FileEntry {
|
|
189
|
+
export interface FileEntry {
|
|
237
190
|
path: string;
|
|
238
191
|
ins: number | null;
|
|
239
192
|
del: number | null;
|
package/src/stats/data.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { join } from "node:path";
|
|
|
5
5
|
import { type Event, openDb, PLAN_DIR, type Run } from "../db/index.js";
|
|
6
6
|
import { loadConfig } from "../db/load.js";
|
|
7
7
|
import { enrichGateWindows } from "../gates.js";
|
|
8
|
-
import { avg, minutesBetween } from "../math.js";
|
|
9
8
|
import { REASON_CODES, REGIME_CODES } from "../mcp/types.js";
|
|
10
9
|
import {
|
|
11
10
|
isPassFamily,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
readPassiveUsage,
|
|
21
20
|
readZcodeUsage,
|
|
22
21
|
} from "../session/index.js";
|
|
22
|
+
import { avg, minutesBetween } from "../util.js";
|
|
23
23
|
|
|
24
24
|
// Walks events per run in order and pairs up spawn→route (executor time)
|
|
25
25
|
// and route→gate (review turnaround) per round, since one run row can span
|
package/src/telemetry.ts
CHANGED
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
type Run,
|
|
18
18
|
} from "./db/index.js";
|
|
19
19
|
import { enrichGateWindows } from "./gates.js";
|
|
20
|
-
import { avg, minutesBetween } from "./math.js";
|
|
21
20
|
import { readPassiveUsage } from "./session/index.js";
|
|
21
|
+
import { avg, minutesBetween } from "./util.js";
|
|
22
22
|
|
|
23
23
|
// ─── Schema version ────────────────────────────────────────────────────
|
|
24
24
|
|
package/src/util.ts
CHANGED
|
@@ -25,8 +25,69 @@ export function fillPrompt(
|
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
|
|
29
|
+
export function minutesBetween(a: string, b: string): number {
|
|
30
|
+
const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
|
|
31
|
+
const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
|
|
32
|
+
return (t1 - t0) / 60000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Arithmetic mean of a numeric array. 0 for empty arrays. */
|
|
36
|
+
export function avg(xs: number[]): number {
|
|
37
|
+
return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/** True for "y"/"yes" (case-insensitive, trimmed) — the only affirmative answers. */
|
|
29
41
|
export function isAffirmative(answer: string): boolean {
|
|
30
42
|
const normalized = answer.trim().toLowerCase();
|
|
31
43
|
return normalized === "y" || normalized === "yes";
|
|
32
44
|
}
|
|
45
|
+
|
|
46
|
+
// --- Recursive directory walker ---
|
|
47
|
+
|
|
48
|
+
import { readdirSync } from "node:fs";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
|
|
51
|
+
export interface WalkDirOpts {
|
|
52
|
+
/** Max recursion depth (default 4). */
|
|
53
|
+
depth?: number;
|
|
54
|
+
/** Skip node_modules (default true). */
|
|
55
|
+
skipNodeModules?: boolean;
|
|
56
|
+
/** Dot-directories to descend into (default []). Others starting with . are skipped. */
|
|
57
|
+
includeDotDirs?: string[];
|
|
58
|
+
/** Extra filter: return true to collect this dir. Omit to collect all reached dirs. */
|
|
59
|
+
predicate?: (dirPath: string) => boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recursively walk directories under `root`.
|
|
64
|
+
* Returns absolute paths of dirs for which `predicate` returned true (or all
|
|
65
|
+
* reached dirs when no predicate is given). Skips node_modules by default.
|
|
66
|
+
*/
|
|
67
|
+
export function walkDir(root: string, opts?: WalkDirOpts): string[] {
|
|
68
|
+
const maxDepth = opts?.depth ?? 4;
|
|
69
|
+
const skipNm = opts?.skipNodeModules ?? true;
|
|
70
|
+
const includeDot = new Set(opts?.includeDotDirs ?? []);
|
|
71
|
+
const pred = opts?.predicate;
|
|
72
|
+
const result: string[] = [];
|
|
73
|
+
|
|
74
|
+
const walk = (dir: string, depth: number): void => {
|
|
75
|
+
if (depth > maxDepth) return;
|
|
76
|
+
if (!pred || pred(dir)) result.push(dir);
|
|
77
|
+
try {
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (!entry.isDirectory()) continue;
|
|
80
|
+
if (entry.isSymbolicLink()) continue;
|
|
81
|
+
if (skipNm && entry.name === "node_modules") continue;
|
|
82
|
+
if (entry.name.startsWith(".") && !includeDot.has(entry.name)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
walk(join(dir, entry.name), depth + 1);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore unreadable dirs
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
walk(root, 0);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
package/templates/SPEC.md
CHANGED
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
+
## Non-goals
|
|
18
|
+
What this explicitly does **not** do, one line of why each. Widening scope is an
|
|
19
|
+
agent's default; a spec that only says what to build never stopped anything.
|
|
20
|
+
|
|
17
21
|
## Shape (data / API / schema)
|
|
18
22
|
Concrete types, request/response bodies, DB columns — whatever the code needs.
|
|
19
23
|
|
|
20
24
|
## Edge cases
|
|
21
|
-
|
|
25
|
+
Three columns: input → expected → **verify** (a command that actually runs).
|
|
26
|
+
An edge case with no check after it is an opinion, not a requirement — and a
|
|
27
|
+
verify built from a substring of the prose is not a verify (real case: `grep "sed"`
|
|
28
|
+
matched "superseded", so the check passed while the work was missing).
|
|
22
29
|
|
|
23
30
|
## Examples
|
|
24
31
|
Before / after, request / response, sample payloads — as long as it needs to be.
|
package/images/logo.png
DELETED
|
Binary file
|
package/images/logo.webp
DELETED
|
Binary file
|
package/images/logo@400.webp
DELETED
|
Binary file
|
package/images/sample.webp
DELETED
|
Binary file
|
package/images/summary.webp
DELETED
|
Binary file
|
package/src/math.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// src/math.ts — pure numeric helpers shared across modules
|
|
2
|
-
|
|
3
|
-
/** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
|
|
4
|
-
export function minutesBetween(a: string, b: string): number {
|
|
5
|
-
const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
|
|
6
|
-
const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
|
|
7
|
-
return (t1 - t0) / 60000;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Arithmetic mean of a numeric array. 0 for empty arrays. */
|
|
11
|
-
export function avg(xs: number[]): number {
|
|
12
|
-
return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
|
|
13
|
-
}
|
package/src/mcp/tools/verdict.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
// src/mcp/tools/verdict.ts — verdict_submit tool
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
findOpenRun,
|
|
5
|
-
findOpenRunWithNullPlan,
|
|
6
|
-
getRun,
|
|
7
|
-
newRun,
|
|
8
|
-
openDb,
|
|
9
|
-
patchLastGateEvent,
|
|
10
|
-
} from "../../db/index.js";
|
|
11
|
-
import { gateOnce } from "../../gate.js";
|
|
12
|
-
import { VERDICT_GRADES, type VerdictGrade } from "../../parse.js";
|
|
13
|
-
import {
|
|
14
|
-
errorResult,
|
|
15
|
-
jsonResult,
|
|
16
|
-
REASON_CODES,
|
|
17
|
-
REGIME_CODES,
|
|
18
|
-
type ReasonCode,
|
|
19
|
-
type RegimeCode,
|
|
20
|
-
type ToolResult,
|
|
21
|
-
} from "../types.js";
|
|
22
|
-
import { resolveWorktreeArg } from "../worktree.js";
|
|
23
|
-
|
|
24
|
-
// --- Tool implementation ---
|
|
25
|
-
|
|
26
|
-
export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
|
|
27
|
-
const {
|
|
28
|
-
run_id,
|
|
29
|
-
verdict,
|
|
30
|
-
reason_code,
|
|
31
|
-
regime,
|
|
32
|
-
note,
|
|
33
|
-
worktree,
|
|
34
|
-
plan,
|
|
35
|
-
session_id,
|
|
36
|
-
files,
|
|
37
|
-
} = args;
|
|
38
|
-
|
|
39
|
-
if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) {
|
|
40
|
-
return errorResult(
|
|
41
|
-
`verdict must be one of: ${[...VERDICT_GRADES].join(", ")}`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
const grade = verdict as VerdictGrade;
|
|
45
|
-
if (!REASON_CODES.includes(reason_code as ReasonCode)) {
|
|
46
|
-
return errorResult(
|
|
47
|
-
`reason_code must be one of: ${REASON_CODES.join(", ")}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
if (reason_code === "other" && (!note || typeof note !== "string")) {
|
|
51
|
-
return errorResult("reason_code 'other' requires a note");
|
|
52
|
-
}
|
|
53
|
-
if (!REGIME_CODES.includes(regime as RegimeCode)) {
|
|
54
|
-
return errorResult(`regime must be one of: ${REGIME_CODES.join(", ")}`);
|
|
55
|
-
}
|
|
56
|
-
const regimeCode = regime as RegimeCode;
|
|
57
|
-
|
|
58
|
-
const db = openDb();
|
|
59
|
-
|
|
60
|
-
// Resolve or create run_id
|
|
61
|
-
let resolvedRunId: number;
|
|
62
|
-
if (typeof run_id === "number" && Number.isInteger(run_id)) {
|
|
63
|
-
const run = getRun(db, run_id);
|
|
64
|
-
if (!run) {
|
|
65
|
-
return jsonResult({ stored: false, error: "run not found" });
|
|
66
|
-
}
|
|
67
|
-
resolvedRunId = run_id;
|
|
68
|
-
} else {
|
|
69
|
-
// Bind to the latest still-open run for the same worktree+plan so a
|
|
70
|
-
// round-2+ verdict lands on the original row (round keeps counting and
|
|
71
|
-
// review.maxRounds can actually trigger). Only when no open run matches
|
|
72
|
-
// is a fresh row created. plan=null always opens a new run (no guess).
|
|
73
|
-
// Free-text plans are normalized (trim+lowercase) so "Fix Login" and
|
|
74
|
-
// "fix login" bind to the same run. When no exact match exists, falls
|
|
75
|
-
// back to any open run with plan=null (the "no PLAN file" flow).
|
|
76
|
-
const resolvedWorktree =
|
|
77
|
-
typeof worktree === "string" && worktree
|
|
78
|
-
? resolveWorktreeArg(worktree)
|
|
79
|
-
: "mcp-external";
|
|
80
|
-
const resolvedPlan =
|
|
81
|
-
typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
|
|
82
|
-
// BEGIN IMMEDIATE makes the find-then-create below atomic across
|
|
83
|
-
// concurrent MCP processes: without it, two agents closing the same
|
|
84
|
-
// worktree+plan at once can both see "no open run" and each insert a
|
|
85
|
-
// row. The write lock taken here blocks (busy_timeout, not throws) the
|
|
86
|
-
// second caller until the first commits, so it re-reads and finds the
|
|
87
|
-
// row already there instead of duplicating it.
|
|
88
|
-
db.run("BEGIN IMMEDIATE");
|
|
89
|
-
try {
|
|
90
|
-
let open = resolvedPlan
|
|
91
|
-
? findOpenRun(db, resolvedWorktree, resolvedPlan)
|
|
92
|
-
: null;
|
|
93
|
-
// Fallback: if no exact match and plan is non-null, try any open run with
|
|
94
|
-
// plan=null. This lets a verdict with free-text intent bind to a run that
|
|
95
|
-
// was created without a plan (the common "no PLAN file" flow).
|
|
96
|
-
if (!open && resolvedPlan) {
|
|
97
|
-
open = findOpenRunWithNullPlan(db, resolvedWorktree);
|
|
98
|
-
}
|
|
99
|
-
if (open) {
|
|
100
|
-
resolvedRunId = open.id;
|
|
101
|
-
} else {
|
|
102
|
-
// worktree/plan let callers (e.g. move-to-done) attribute the verdict
|
|
103
|
-
// so byReasonCode/bestPassing aggregate correctly instead of collapsing
|
|
104
|
-
// into "mcp-external".
|
|
105
|
-
resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
|
|
106
|
-
}
|
|
107
|
-
db.run("COMMIT");
|
|
108
|
-
} catch (e) {
|
|
109
|
-
db.run("ROLLBACK");
|
|
110
|
-
throw e;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Normalize files: must be a non-empty array of strings.
|
|
115
|
-
const resolvedFiles =
|
|
116
|
-
Array.isArray(files) && files.length > 0
|
|
117
|
-
? files.filter((f): f is string => typeof f === "string" && f.length > 0)
|
|
118
|
-
: undefined;
|
|
119
|
-
|
|
120
|
-
// Route through gateOnce for consistent status/round/memory handling.
|
|
121
|
-
const mcpNote =
|
|
122
|
-
typeof note === "string" && note
|
|
123
|
-
? `[${reason_code}] ${note}`
|
|
124
|
-
: `[${reason_code}]`;
|
|
125
|
-
const result = gateOnce(resolvedRunId, grade, mcpNote, resolvedFiles);
|
|
126
|
-
|
|
127
|
-
if (result.error) {
|
|
128
|
-
return jsonResult({
|
|
129
|
-
stored: false,
|
|
130
|
-
error: result.error,
|
|
131
|
-
run_id: resolvedRunId,
|
|
132
|
-
status: result.status,
|
|
133
|
-
round: result.round,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Patch the gate event with MCP-specific fields (reason_code, source).
|
|
138
|
-
// session_id lets gates.ts resolve model from client session logs when the
|
|
139
|
-
// window has no spawn events (the old execute→review loop that wrote spawns
|
|
140
|
-
// is gone). Optional — agents that can't expose it just omit it.
|
|
141
|
-
const patch: Record<string, unknown> = {
|
|
142
|
-
reason_code,
|
|
143
|
-
regime: regimeCode,
|
|
144
|
-
source: "mcp",
|
|
145
|
-
};
|
|
146
|
-
if (typeof session_id === "string" && session_id) {
|
|
147
|
-
patch.session_id = session_id;
|
|
148
|
-
}
|
|
149
|
-
patchLastGateEvent(db, resolvedRunId, patch);
|
|
150
|
-
|
|
151
|
-
return jsonResult({
|
|
152
|
-
stored: true,
|
|
153
|
-
run_id: resolvedRunId,
|
|
154
|
-
verdict: grade,
|
|
155
|
-
reason_code,
|
|
156
|
-
regime: regimeCode,
|
|
157
|
-
status: result.status,
|
|
158
|
-
round: result.round,
|
|
159
|
-
...(resolvedFiles ? { files: resolvedFiles } : {}),
|
|
160
|
-
});
|
|
161
|
-
}
|