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/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
- now | done | stale views
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
- // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
142
- cmdNow();
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 "./analyze.js";
39
- import { computeModelFit } from "./context/projectHealth.js";
40
- import { doneDir, planDir, specDir } from "./db/getters.js";
41
- import { CONFIG_FILENAME } from "./db/index.js";
42
- import { loadConfig } from "./db/load.js";
43
- import type { Config } from "./db/types.js";
44
- import { extractExports } from "./map.js";
45
- import { readRecentMemDecisions } from "./memory.js";
46
- import { getStatsData } from "./stats/data.js";
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 · \`verdict_submit\` (MCP) with this step's \`regime\` · then hand off:
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
- let worktree: string;
534
- try {
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 the ledger (verdict_submit), never in
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 "./analyze.js";
34
- import { extractBody, extractExports } from "./map.js";
35
- import { assertSafe } from "./safety.js";
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
- Table or bullets: input → expected behavior.
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
Binary file
Binary file
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
- }
@@ -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
- }