fapony 0.1.0
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/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// src/mcp/primitives.ts — shared verification primitives
|
|
2
|
+
//
|
|
3
|
+
// Types, enums, and helpers reused by collect, check, evidence, and
|
|
4
|
+
// verification_report. Keeps tool implementations thin and ensures
|
|
5
|
+
// consistent shapes across the MCP surface.
|
|
6
|
+
//
|
|
7
|
+
// §0 rule: add-only — never remove or rename exported symbols.
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import type { BlastEntry } from "../analyze.js";
|
|
11
|
+
import { ROOT } from "../update.js";
|
|
12
|
+
|
|
13
|
+
// ─── Server build identity ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
let cachedServerSha: string | null | undefined; // undefined = not yet computed
|
|
16
|
+
|
|
17
|
+
/** Short git SHA of the fapony server itself, cached for the process life. */
|
|
18
|
+
export function getServerSha(): string | null {
|
|
19
|
+
if (cachedServerSha !== undefined) return cachedServerSha;
|
|
20
|
+
try {
|
|
21
|
+
cachedServerSha = execSync("git rev-parse --short HEAD", {
|
|
22
|
+
cwd: ROOT,
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
25
|
+
timeout: 5_000,
|
|
26
|
+
}).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
cachedServerSha = null;
|
|
29
|
+
}
|
|
30
|
+
return cachedServerSha;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Evidence status vocabulary (locked, additive-only) ───────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Evidence command result status.
|
|
37
|
+
* - `passed`: command exited 0, output matches expectation
|
|
38
|
+
* - `failed`: command exited non-zero or output mismatch
|
|
39
|
+
* - `not_run`: command not configured or explicitly skipped
|
|
40
|
+
* - `unverified`: agent claim, not yet re-run by fapony
|
|
41
|
+
* - `timeout`: command exceeded per-command timeout
|
|
42
|
+
*/
|
|
43
|
+
export type EvidenceStatus =
|
|
44
|
+
| "passed"
|
|
45
|
+
| "failed"
|
|
46
|
+
| "not_run"
|
|
47
|
+
| "unverified"
|
|
48
|
+
| "timeout";
|
|
49
|
+
|
|
50
|
+
export const EVIDENCE_STATUSES: ReadonlySet<string> = new Set<EvidenceStatus>([
|
|
51
|
+
"passed",
|
|
52
|
+
"failed",
|
|
53
|
+
"not_run",
|
|
54
|
+
"unverified",
|
|
55
|
+
"timeout",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// ─── Evidence item ────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface EvidenceItem {
|
|
61
|
+
/** Command identity (e.g. "bun test", "bun run typecheck"). */
|
|
62
|
+
command: string;
|
|
63
|
+
/** Structured result status. */
|
|
64
|
+
status: EvidenceStatus;
|
|
65
|
+
/** Exit code, or null when not_run / timeout / refused by safety gate. */
|
|
66
|
+
exit_code: number | null;
|
|
67
|
+
/** Wall-clock duration in ms, or null when not_run / refused. */
|
|
68
|
+
duration_ms: number | null;
|
|
69
|
+
/** Where the result came from. */
|
|
70
|
+
provenance: EvidenceProvenance;
|
|
71
|
+
/** Optional note (reason for not_run, truncated output, etc.). */
|
|
72
|
+
note?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Provenance ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface EvidenceProvenance {
|
|
78
|
+
/** true = fapony ran the command itself; false = agent claim. */
|
|
79
|
+
verified: boolean;
|
|
80
|
+
/** Source identifier: "fapony_cli", "agent_report", "config_allowlist". */
|
|
81
|
+
source: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Check result (shared by handoff_check + verification_report) ─────
|
|
85
|
+
|
|
86
|
+
export interface CheckResult {
|
|
87
|
+
name: string;
|
|
88
|
+
pass: boolean;
|
|
89
|
+
note: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Report summary ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface EvidenceSummary {
|
|
95
|
+
total: number;
|
|
96
|
+
passed: number;
|
|
97
|
+
failed: number;
|
|
98
|
+
not_run: number;
|
|
99
|
+
unverified: number;
|
|
100
|
+
timeout: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function computeEvidenceSummary(items: EvidenceItem[]): EvidenceSummary {
|
|
104
|
+
const s: EvidenceSummary = {
|
|
105
|
+
total: items.length,
|
|
106
|
+
passed: 0,
|
|
107
|
+
failed: 0,
|
|
108
|
+
not_run: 0,
|
|
109
|
+
unverified: 0,
|
|
110
|
+
timeout: 0,
|
|
111
|
+
};
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
s[item.status]++;
|
|
114
|
+
}
|
|
115
|
+
return s;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Verification report contract ─────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Full verification report — the single object returned by
|
|
122
|
+
* `verification_report` MCP tool and rendered by CLI.
|
|
123
|
+
*
|
|
124
|
+
* Design: compose-only — aggregates results from existing primitives
|
|
125
|
+
* (handoff_collect, handoff_check, verdict, stats) plus evidence
|
|
126
|
+
* collection. No new parser/conformance logic.
|
|
127
|
+
*/
|
|
128
|
+
export interface VerificationReport {
|
|
129
|
+
/** Git facts from handoff_collect (verified by fapony). */
|
|
130
|
+
facts: {
|
|
131
|
+
files_changed: number;
|
|
132
|
+
lines_changed: number;
|
|
133
|
+
insertions: number;
|
|
134
|
+
deletions: number;
|
|
135
|
+
commits: string[];
|
|
136
|
+
branch: string;
|
|
137
|
+
git_error: string | null;
|
|
138
|
+
/** Changed file paths (from handoff_collect) — absent on old reports. */
|
|
139
|
+
files?: string[];
|
|
140
|
+
};
|
|
141
|
+
/** Handoff conformance checks (from handoff_check). */
|
|
142
|
+
handoff_checks: {
|
|
143
|
+
checks: CheckResult[];
|
|
144
|
+
summary: {
|
|
145
|
+
total: number;
|
|
146
|
+
passed: number;
|
|
147
|
+
failed: number;
|
|
148
|
+
needs_human_review: boolean;
|
|
149
|
+
};
|
|
150
|
+
} | null;
|
|
151
|
+
/**
|
|
152
|
+
* Blast radius of the changed files (from `fapony analyze` graph, live).
|
|
153
|
+
* Null/absent when no files[] or the worktree can't be scanned.
|
|
154
|
+
*/
|
|
155
|
+
blast_radius?: Record<string, BlastEntry> | null;
|
|
156
|
+
/** Evidence items (test, typecheck, lint, etc.). */
|
|
157
|
+
evidence: EvidenceItem[];
|
|
158
|
+
/** Evidence summary (computed from evidence array). */
|
|
159
|
+
evidence_summary: EvidenceSummary;
|
|
160
|
+
/** Verdict grade + note (from gate/verdict_submit, or null if not yet). */
|
|
161
|
+
verdict: {
|
|
162
|
+
grade: string;
|
|
163
|
+
note: string;
|
|
164
|
+
} | null;
|
|
165
|
+
/** Run duration in ms (created_at → updated_at), or null. */
|
|
166
|
+
duration_ms: number | null;
|
|
167
|
+
/** Number of rounds executed. */
|
|
168
|
+
rounds: number;
|
|
169
|
+
/** Report metadata. */
|
|
170
|
+
meta: {
|
|
171
|
+
/** ISO-8601 timestamp of report generation. */
|
|
172
|
+
generated_at: string;
|
|
173
|
+
/** Provenance: who produced this report. */
|
|
174
|
+
source: "fapony_mcp" | "fapony_cli";
|
|
175
|
+
/** Run ID this report is for. */
|
|
176
|
+
run_id: number | null;
|
|
177
|
+
/**
|
|
178
|
+
* Short git SHA of the fapony server process itself (not the worktree
|
|
179
|
+
* being reviewed), captured once at first use and cached for the life
|
|
180
|
+
* of the process. Lets a reader compare against `git log -1` in the
|
|
181
|
+
* fapony repo to catch a stale (pre-edit) server still answering.
|
|
182
|
+
* Null when fapony isn't running from a git checkout.
|
|
183
|
+
*/
|
|
184
|
+
server_sha: string | null;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Text renderer ────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function fmtDuration(ms: number | null): string {
|
|
191
|
+
if (ms === null) return "—";
|
|
192
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
193
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function statusIcon(s: EvidenceStatus): string {
|
|
197
|
+
switch (s) {
|
|
198
|
+
case "passed":
|
|
199
|
+
return "✓";
|
|
200
|
+
case "failed":
|
|
201
|
+
return "✗";
|
|
202
|
+
case "not_run":
|
|
203
|
+
return "○";
|
|
204
|
+
case "unverified":
|
|
205
|
+
return "?";
|
|
206
|
+
case "timeout":
|
|
207
|
+
return "⏱";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Render VerificationReport as human-readable text.
|
|
213
|
+
* Same data as JSON — never disagrees.
|
|
214
|
+
*/
|
|
215
|
+
export function renderReportText(report: VerificationReport): string {
|
|
216
|
+
const lines: string[] = [];
|
|
217
|
+
|
|
218
|
+
lines.push("=== Verification Report ===");
|
|
219
|
+
lines.push(
|
|
220
|
+
`run: ${report.meta.run_id ?? "(none)"} generated: ${report.meta.generated_at}`,
|
|
221
|
+
);
|
|
222
|
+
lines.push(
|
|
223
|
+
`fapony server: ${report.meta.server_sha ?? "(unknown — not a git checkout)"} — compare with \`git log -1\` in the fapony repo`,
|
|
224
|
+
);
|
|
225
|
+
lines.push("");
|
|
226
|
+
|
|
227
|
+
// --- Git facts ---
|
|
228
|
+
lines.push("--- git facts ---");
|
|
229
|
+
if (report.facts.git_error) {
|
|
230
|
+
lines.push(`⚠ ${report.facts.git_error}`);
|
|
231
|
+
}
|
|
232
|
+
lines.push(`branch: ${report.facts.branch}`);
|
|
233
|
+
lines.push(`files changed: ${report.facts.files_changed}`);
|
|
234
|
+
lines.push(`lines changed: ${report.facts.lines_changed}`);
|
|
235
|
+
lines.push(
|
|
236
|
+
`commits: ${report.facts.commits.length ? report.facts.commits.join(", ") : "(none)"}`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// --- Blast radius ---
|
|
240
|
+
if (report.blast_radius && Object.keys(report.blast_radius).length > 0) {
|
|
241
|
+
lines.push("");
|
|
242
|
+
lines.push("--- blast radius ---");
|
|
243
|
+
for (const [file, b] of Object.entries(report.blast_radius)) {
|
|
244
|
+
const flag = b.tested ? "tested" : "⚠ no test imports it";
|
|
245
|
+
lines.push(
|
|
246
|
+
` ${file}: ${b.dependents} dependent${b.dependents === 1 ? "" : "s"} — ${flag}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Handoff checks ---
|
|
252
|
+
if (report.handoff_checks) {
|
|
253
|
+
lines.push("");
|
|
254
|
+
lines.push("--- handoff conformance ---");
|
|
255
|
+
const hs = report.handoff_checks.summary;
|
|
256
|
+
lines.push(
|
|
257
|
+
`${hs.passed}/${hs.total} passed${hs.needs_human_review ? " (needs human review)" : ""}`,
|
|
258
|
+
);
|
|
259
|
+
for (const c of report.handoff_checks.checks) {
|
|
260
|
+
const icon = c.pass ? "✓" : "✗";
|
|
261
|
+
lines.push(` ${icon} ${c.name}${c.note ? ` — ${c.note}` : ""}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Evidence ---
|
|
266
|
+
lines.push("");
|
|
267
|
+
lines.push("--- evidence ---");
|
|
268
|
+
if (report.evidence.length === 0) {
|
|
269
|
+
lines.push("not_run (no evidence allowlist found)");
|
|
270
|
+
} else {
|
|
271
|
+
const es = report.evidence_summary;
|
|
272
|
+
lines.push(
|
|
273
|
+
`${es.passed} passed, ${es.failed} failed, ${es.not_run} not run, ${es.unverified} unverified, ${es.timeout} timeout`,
|
|
274
|
+
);
|
|
275
|
+
for (const item of report.evidence) {
|
|
276
|
+
const dur =
|
|
277
|
+
item.duration_ms !== null ? ` (${fmtDuration(item.duration_ms)})` : "";
|
|
278
|
+
const note = item.note ? ` — ${item.note}` : "";
|
|
279
|
+
lines.push(` ${statusIcon(item.status)} ${item.command}${dur}${note}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Verdict ---
|
|
284
|
+
lines.push("");
|
|
285
|
+
if (report.verdict) {
|
|
286
|
+
lines.push(`--- verdict: ${report.verdict.grade} ---`);
|
|
287
|
+
if (report.verdict.note) lines.push(report.verdict.note);
|
|
288
|
+
} else {
|
|
289
|
+
lines.push("--- verdict: (not yet) ---");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Duration & rounds ---
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push(
|
|
295
|
+
`duration: ${fmtDuration(report.duration_ms)} rounds: ${report.rounds}`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// --- Next action ---
|
|
299
|
+
lines.push("");
|
|
300
|
+
lines.push("--- next action ---");
|
|
301
|
+
if (report.verdict?.grade.startsWith("pass")) {
|
|
302
|
+
lines.push("All checks passed. No action needed.");
|
|
303
|
+
} else if (report.evidence_summary.failed > 0) {
|
|
304
|
+
lines.push("Fix failing evidence commands, then re-run verification.");
|
|
305
|
+
} else if (report.evidence_summary.unverified > 0) {
|
|
306
|
+
lines.push("Re-run unverified items or accept agent claims with caution.");
|
|
307
|
+
} else if (report.handoff_checks?.summary.needs_human_review) {
|
|
308
|
+
lines.push("Review handoff conformance failures above.");
|
|
309
|
+
} else if (!report.verdict) {
|
|
310
|
+
lines.push("Submit a verdict to complete verification.");
|
|
311
|
+
} else {
|
|
312
|
+
lines.push("Review the report and decide next steps.");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// src/mcp/tools/check.ts — handoff_check tool
|
|
2
|
+
|
|
3
|
+
import { blastRadiusForWorktree } from "../../analyze.js";
|
|
4
|
+
import type { CheckResult } from "../primitives.js";
|
|
5
|
+
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// --- Helpers ---
|
|
8
|
+
|
|
9
|
+
export function extractMultiField(text: string, field: string): string[] {
|
|
10
|
+
const regex = new RegExp(`${field}:\\s*(.+)`, "i");
|
|
11
|
+
const match = text.match(regex);
|
|
12
|
+
if (!match) return [];
|
|
13
|
+
|
|
14
|
+
const firstLine = match[1].trim();
|
|
15
|
+
if (!firstLine || firstLine === "none") return [];
|
|
16
|
+
|
|
17
|
+
const result = [firstLine];
|
|
18
|
+
const lines = text.split("\n");
|
|
19
|
+
const startIdx = lines.findIndex((l) => l.trim().startsWith(`${field}:`));
|
|
20
|
+
|
|
21
|
+
const FAPONY_FIELDS = [
|
|
22
|
+
"claimed",
|
|
23
|
+
"commits",
|
|
24
|
+
"checks",
|
|
25
|
+
"uncertain",
|
|
26
|
+
"not_done",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
30
|
+
const line = lines[i].trim();
|
|
31
|
+
if (!line) break; // empty line ends the field
|
|
32
|
+
if (FAPONY_FIELDS.some((f) => line.startsWith(`${f}:`))) break; // new field
|
|
33
|
+
result.push(line);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Tool implementation ---
|
|
40
|
+
|
|
41
|
+
export function toolHandoffCheck(args: Record<string, unknown>): ToolResult {
|
|
42
|
+
const {
|
|
43
|
+
handoff,
|
|
44
|
+
facts,
|
|
45
|
+
auto_generate,
|
|
46
|
+
uncertain,
|
|
47
|
+
not_done,
|
|
48
|
+
checks: checksArg,
|
|
49
|
+
worktree,
|
|
50
|
+
} = args;
|
|
51
|
+
|
|
52
|
+
// Track which fields the agent actually reported (vs auto-generated)
|
|
53
|
+
const reported: Record<string, boolean> = {};
|
|
54
|
+
if (typeof uncertain === "string") reported.uncertain = true;
|
|
55
|
+
if (typeof not_done === "string") reported.not_done = true;
|
|
56
|
+
if (typeof checksArg === "string") reported.checks = true;
|
|
57
|
+
|
|
58
|
+
// Auto-generate claimed/commits from facts if requested
|
|
59
|
+
let resolvedHandoff = handoff;
|
|
60
|
+
if (auto_generate === true && typeof resolvedHandoff !== "string") {
|
|
61
|
+
if (facts && typeof facts === "object") {
|
|
62
|
+
const f = facts as Record<string, unknown>;
|
|
63
|
+
const commits = Array.isArray(f.commits) ? (f.commits as string[]) : [];
|
|
64
|
+
const headSha =
|
|
65
|
+
commits.length > 0 ? commits[commits.length - 1] : "unknown";
|
|
66
|
+
const commitsStr = commits.length > 0 ? commits.join(" ") : "none";
|
|
67
|
+
|
|
68
|
+
const lines = [
|
|
69
|
+
"## HANDOFF",
|
|
70
|
+
`claimed: ${headSha}`,
|
|
71
|
+
`commits: ${commitsStr}`,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Only include uncertain/not_done/checks if agent actually provided them
|
|
75
|
+
if (typeof uncertain === "string") {
|
|
76
|
+
lines.push(`uncertain: ${uncertain}`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof not_done === "string") {
|
|
79
|
+
lines.push(`not_done: ${not_done}`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof checksArg === "string") {
|
|
82
|
+
lines.push(`checks: ${checksArg}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
resolvedHandoff = lines.join("\n");
|
|
86
|
+
} else {
|
|
87
|
+
return errorResult("auto_generate requires facts with commits");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof resolvedHandoff !== "string") {
|
|
92
|
+
return errorResult(
|
|
93
|
+
"handoff is required as a string (or set auto_generate=true with facts)",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const checkResults: CheckResult[] = [];
|
|
98
|
+
|
|
99
|
+
// 1. has_handoff_block
|
|
100
|
+
const hasBlock = resolvedHandoff.includes("## HANDOFF");
|
|
101
|
+
checkResults.push({
|
|
102
|
+
name: "has_handoff_block",
|
|
103
|
+
pass: hasBlock,
|
|
104
|
+
note: hasBlock ? "" : "no ## HANDOFF block found",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!hasBlock) {
|
|
108
|
+
return jsonResult({
|
|
109
|
+
checks: checkResults,
|
|
110
|
+
summary: { total: 1, passed: 0, failed: 1, needs_human_review: true },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Extract claimed
|
|
115
|
+
const claimedMatch = resolvedHandoff.match(/claimed:\s*(.+)/i);
|
|
116
|
+
const claimed = claimedMatch?.[1]?.trim() ?? "";
|
|
117
|
+
|
|
118
|
+
// Extract commits
|
|
119
|
+
const commitsMatch = resolvedHandoff.match(/commits:\s*(.+)/i);
|
|
120
|
+
const commitsStr = commitsMatch?.[1]?.trim() ?? "";
|
|
121
|
+
const handoffCommits =
|
|
122
|
+
commitsStr && commitsStr !== "none"
|
|
123
|
+
? commitsStr.split(/\s+/).filter(Boolean)
|
|
124
|
+
: [];
|
|
125
|
+
|
|
126
|
+
// Extract checks
|
|
127
|
+
const checksMatch = resolvedHandoff.match(/checks:\s*(.+)/i);
|
|
128
|
+
const checksField = checksMatch?.[1]?.trim() ?? "";
|
|
129
|
+
|
|
130
|
+
// Extract uncertain (multi-line)
|
|
131
|
+
const uncertainLines = extractMultiField(resolvedHandoff, "uncertain");
|
|
132
|
+
const notDoneLines = extractMultiField(resolvedHandoff, "not_done");
|
|
133
|
+
|
|
134
|
+
// 2. claimed_matches_commits
|
|
135
|
+
if (claimed && claimed !== "none") {
|
|
136
|
+
const claimedInCommits = handoffCommits.some((c) => claimed.startsWith(c));
|
|
137
|
+
checkResults.push({
|
|
138
|
+
name: "claimed_matches_commits",
|
|
139
|
+
pass: claimedInCommits,
|
|
140
|
+
note: claimedInCommits
|
|
141
|
+
? `claimed commit ${claimed} found in handoff commits`
|
|
142
|
+
: `claimed commit ${claimed} not found in handoff commits`,
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
checkResults.push({
|
|
146
|
+
name: "claimed_matches_commits",
|
|
147
|
+
pass: true,
|
|
148
|
+
note: "no claimed commit to verify",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. uncertain_not_empty — fail if agent didn't report OR reported something
|
|
153
|
+
const hasUncertain = uncertainLines.length > 0;
|
|
154
|
+
const uncertainReported = reported.uncertain === true;
|
|
155
|
+
checkResults.push({
|
|
156
|
+
name: "uncertain_not_empty",
|
|
157
|
+
pass: !hasUncertain && uncertainReported,
|
|
158
|
+
note: hasUncertain
|
|
159
|
+
? `executor flagged uncertainty: ${uncertainLines.join("; ")}`
|
|
160
|
+
: !uncertainReported
|
|
161
|
+
? "agent did not report uncertainty (required)"
|
|
162
|
+
: "",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// 4. not_done_not_empty — fail if agent didn't report OR reported something
|
|
166
|
+
const hasNotDone = notDoneLines.length > 0;
|
|
167
|
+
const notDoneReported = reported.not_done === true;
|
|
168
|
+
checkResults.push({
|
|
169
|
+
name: "not_done_not_empty",
|
|
170
|
+
pass: !hasNotDone && notDoneReported,
|
|
171
|
+
note: hasNotDone
|
|
172
|
+
? `executor reported incomplete: ${notDoneLines.join("; ")}`
|
|
173
|
+
: !notDoneReported
|
|
174
|
+
? "agent did not report not_done (required)"
|
|
175
|
+
: "",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 5. checks_declared — fail if agent didn't report OR reported empty
|
|
179
|
+
const hasChecks = checksField.length > 0 && checksField !== "none";
|
|
180
|
+
const checksReported = reported.checks === true;
|
|
181
|
+
checkResults.push({
|
|
182
|
+
name: "checks_declared",
|
|
183
|
+
pass: hasChecks && checksReported,
|
|
184
|
+
note: !checksReported
|
|
185
|
+
? "agent did not report checks (required)"
|
|
186
|
+
: hasChecks
|
|
187
|
+
? "checks field present"
|
|
188
|
+
: "no checks field in handoff",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// 6. facts_cross_referenced — only when facts provided
|
|
192
|
+
if (facts && typeof facts === "object") {
|
|
193
|
+
const f = facts as Record<string, unknown>;
|
|
194
|
+
const factCommits = Array.isArray(f.commits) ? (f.commits as string[]) : [];
|
|
195
|
+
if (factCommits.length > 0 && handoffCommits.length > 0) {
|
|
196
|
+
const matchCount = handoffCommits.filter((hc) =>
|
|
197
|
+
factCommits.some((fc) => fc.startsWith(hc) || hc.startsWith(fc)),
|
|
198
|
+
).length;
|
|
199
|
+
const allMatch = matchCount === handoffCommits.length;
|
|
200
|
+
checkResults.push({
|
|
201
|
+
name: "facts_cross_referenced",
|
|
202
|
+
pass: allMatch,
|
|
203
|
+
note: `${matchCount}/${handoffCommits.length} commits in handoff match git facts`,
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
checkResults.push({
|
|
207
|
+
name: "facts_cross_referenced",
|
|
208
|
+
pass: true,
|
|
209
|
+
note: "no commits to cross-reference",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// If no facts provided, the check is skipped entirely (not added to checks[])
|
|
214
|
+
|
|
215
|
+
const passed = checkResults.filter((c) => c.pass).length;
|
|
216
|
+
const failed = checkResults.filter((c) => !c.pass).length;
|
|
217
|
+
|
|
218
|
+
const out: Record<string, unknown> = {
|
|
219
|
+
checks: checkResults,
|
|
220
|
+
summary: {
|
|
221
|
+
total: checkResults.length,
|
|
222
|
+
passed,
|
|
223
|
+
failed,
|
|
224
|
+
needs_human_review: failed > 0,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Blast radius: only when the caller supplies a worktree to graph against.
|
|
229
|
+
// facts.files[] comes from handoff_collect. Never throws — null on failure.
|
|
230
|
+
if (typeof worktree === "string") {
|
|
231
|
+
const changed =
|
|
232
|
+
facts &&
|
|
233
|
+
typeof facts === "object" &&
|
|
234
|
+
Array.isArray((facts as Record<string, unknown>).files)
|
|
235
|
+
? ((facts as Record<string, unknown>).files as unknown[]).filter(
|
|
236
|
+
(f): f is string => typeof f === "string",
|
|
237
|
+
)
|
|
238
|
+
: [];
|
|
239
|
+
out.blast_radius = blastRadiusForWorktree(worktree, changed);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return jsonResult(out);
|
|
243
|
+
}
|