@sreetej510/pi-shipd-checks 0.1.1
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 +104 -0
- package/package.json +48 -0
- package/src/agents.ts +198 -0
- package/src/command.ts +387 -0
- package/src/config.ts +84 -0
- package/src/git.ts +30 -0
- package/src/index.ts +125 -0
- package/src/progress.ts +11 -0
- package/src/prompts.ts +202 -0
- package/src/report.ts +142 -0
- package/src/roles.ts +20 -0
- package/src/rubric.ts +32 -0
- package/src/state.ts +31 -0
- package/src/tools.ts +140 -0
- package/src/types.ts +56 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/** Registers the `/checks` command: argument parsing, the --config flow, and the main run orchestration. */
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { runGapFinder, runGapValidator, runReviewer } from "./agents.js";
|
|
9
|
+
import {
|
|
10
|
+
getSupportedThinkingLevels,
|
|
11
|
+
loadEnabledModelRefs,
|
|
12
|
+
loadReviewConfig,
|
|
13
|
+
saveReviewConfig,
|
|
14
|
+
splitProviderModel,
|
|
15
|
+
} from "./config.js";
|
|
16
|
+
import { snapshotGitHead } from "./git.js";
|
|
17
|
+
import { PROGRESS_WIDGET_KEY, renderProgressLines } from "./progress.js";
|
|
18
|
+
import { buildRunSummary, loadExistingReport, mergeReport, REQUIRED_FILES } from "./report.js";
|
|
19
|
+
import { ROLES } from "./roles.js";
|
|
20
|
+
import { loadFairnessRules, loadGuidelinesSections } from "./rubric.js";
|
|
21
|
+
import { endReview, isReviewInProgress, startReview } from "./state.js";
|
|
22
|
+
import type {
|
|
23
|
+
CommandOption,
|
|
24
|
+
GapStageResult,
|
|
25
|
+
ReviewerRole,
|
|
26
|
+
ReviewReport,
|
|
27
|
+
TestGapCandidate,
|
|
28
|
+
TestGapFinal,
|
|
29
|
+
ThinkingLevel,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
export const CANCEL_SHORTCUT_LABEL = "Ctrl+Shift+X";
|
|
33
|
+
|
|
34
|
+
const COMMAND_COMPLETIONS: readonly CommandOption[] = [
|
|
35
|
+
{ value: "--all", label: "--all", description: "Run everything: 3 focus reviewers + test-gap finder/filter" },
|
|
36
|
+
{
|
|
37
|
+
value: "--review",
|
|
38
|
+
label: "--review",
|
|
39
|
+
description: "Run only the 3 focus reviewer agents (description/tests/solution)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
value: "--description",
|
|
43
|
+
label: "--description",
|
|
44
|
+
description: "Run only the problem-description (prompt) focus reviewer",
|
|
45
|
+
},
|
|
46
|
+
{ value: "--tests", label: "--tests", description: "Run only the tests focus reviewer" },
|
|
47
|
+
{ value: "--solution", label: "--solution", description: "Run only the solution focus reviewer" },
|
|
48
|
+
{
|
|
49
|
+
value: "--gap-finder",
|
|
50
|
+
label: "--gap-finder",
|
|
51
|
+
description: "Run only the 2-step behavioral test-gap finder + strict filter agents",
|
|
52
|
+
},
|
|
53
|
+
{ value: "--config", label: "--config", description: "Set the reviewer model and thinking level" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function getArgumentCompletions(prefix: string) {
|
|
57
|
+
// Flags are additive (e.g. "--tests --gap-finder"), so completions must
|
|
58
|
+
// account for flags already typed: drop them from suggestions, and keep
|
|
59
|
+
// --config mutually exclusive with everything else.
|
|
60
|
+
const trimmed = prefix.trimStart();
|
|
61
|
+
const trailingSpace = /\s$/.test(trimmed);
|
|
62
|
+
const tokens = trimmed.trimEnd().split(/\s+/).filter(Boolean);
|
|
63
|
+
const current = trailingSpace ? "" : (tokens.at(-1) ?? "");
|
|
64
|
+
const usedTokens = new Set(trailingSpace ? tokens : tokens.slice(0, -1));
|
|
65
|
+
const completionPrefix = trailingSpace ? trimmed : trimmed.slice(0, trimmed.length - current.length);
|
|
66
|
+
|
|
67
|
+
const hasConfig = usedTokens.has("--config");
|
|
68
|
+
const hasOtherFlag = [...usedTokens].some((t) => t !== "--config");
|
|
69
|
+
|
|
70
|
+
const candidates = COMMAND_COMPLETIONS.filter((o) => {
|
|
71
|
+
if (usedTokens.has(o.value)) return false;
|
|
72
|
+
if (hasConfig) return false;
|
|
73
|
+
if (hasOtherFlag && o.value === "--config") return false;
|
|
74
|
+
return o.value.startsWith(current);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return candidates.length > 0 ? candidates.map((o) => ({ ...o, value: `${completionPrefix}${o.value}` })) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Interactive `/checks --config` flow: pick a model (highlighting the current one), then a thinking level. */
|
|
81
|
+
async function runConfigFlow(ctx: ExtensionCommandContext) {
|
|
82
|
+
if (ctx.mode !== "tui") {
|
|
83
|
+
ctx.ui.notify("/checks --config requires interactive mode", "error");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const refs = loadEnabledModelRefs();
|
|
87
|
+
if (refs.length === 0) {
|
|
88
|
+
ctx.ui.notify("No enabledModels configured in settings.json.", "error");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const existingConfig = loadReviewConfig();
|
|
92
|
+
const available = ctx.modelRegistry.getAll();
|
|
93
|
+
const labeled = refs
|
|
94
|
+
.map((ref) => {
|
|
95
|
+
const parsed = splitProviderModel(ref);
|
|
96
|
+
if (!parsed) return null;
|
|
97
|
+
const found = available.find((m: any) => m.provider === parsed.provider && m.id === parsed.modelId);
|
|
98
|
+
const isCurrent = existingConfig?.provider === parsed.provider && existingConfig?.modelId === parsed.modelId;
|
|
99
|
+
const base = found ? `${found.name} (${ref})` : ref;
|
|
100
|
+
return { ref, parsed, display: isCurrent ? `${base} [current]` : base, found, isCurrent };
|
|
101
|
+
})
|
|
102
|
+
.filter((x): x is NonNullable<typeof x> => x !== null);
|
|
103
|
+
|
|
104
|
+
if (labeled.length === 0) {
|
|
105
|
+
ctx.ui.notify("Could not resolve any enabledModels entries.", "error");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Surface the currently configured model at the top of the list, alongside
|
|
110
|
+
// the "[current]" label, since ctx.ui.select can't pre-position the cursor.
|
|
111
|
+
const orderedModels = [...labeled].sort((a, b) => (a.isCurrent === b.isCurrent ? 0 : a.isCurrent ? -1 : 1));
|
|
112
|
+
|
|
113
|
+
const choice = await ctx.ui.select(
|
|
114
|
+
"Select model for checks reviewer agents",
|
|
115
|
+
orderedModels.map((l) => l.display),
|
|
116
|
+
);
|
|
117
|
+
if (!choice) return;
|
|
118
|
+
const selected = labeled.find((l) => l.display === choice);
|
|
119
|
+
if (!selected) return;
|
|
120
|
+
|
|
121
|
+
let thinkingLevel: ThinkingLevel = "off";
|
|
122
|
+
const supportedLevels = getSupportedThinkingLevels(selected.found);
|
|
123
|
+
if (supportedLevels.length > 1) {
|
|
124
|
+
const currentLevel = selected.isCurrent ? existingConfig?.thinkingLevel : undefined;
|
|
125
|
+
const levelOptions = [...supportedLevels].sort((a, b) =>
|
|
126
|
+
(a === currentLevel) === (b === currentLevel) ? 0 : a === currentLevel ? -1 : 1,
|
|
127
|
+
);
|
|
128
|
+
const levelDisplay = (l: ThinkingLevel) => (l === currentLevel ? `${l} [current]` : l);
|
|
129
|
+
const level = await ctx.ui.select("Select thinking level for reviewer agents", levelOptions.map(levelDisplay));
|
|
130
|
+
if (!level) return;
|
|
131
|
+
thinkingLevel = levelOptions.find((l) => levelDisplay(l) === level) ?? (level as ThinkingLevel);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
saveReviewConfig({ provider: selected.parsed.provider, modelId: selected.parsed.modelId, thinkingLevel });
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`checks config saved: ${selected.parsed.provider}/${selected.parsed.modelId} (thinking: ${thinkingLevel})`,
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function registerChecksCommand(pi: ExtensionAPI) {
|
|
142
|
+
pi.registerCommand("checks", {
|
|
143
|
+
description: "Strict review of agent_prompt.md/test.patch/solution.patch. Requires an option — see /checks.",
|
|
144
|
+
getArgumentCompletions,
|
|
145
|
+
|
|
146
|
+
handler: async (args, ctx) => {
|
|
147
|
+
const sub = args.trim();
|
|
148
|
+
|
|
149
|
+
// ── /checks (no args) — list options, run nothing ──
|
|
150
|
+
if (sub === "") {
|
|
151
|
+
ctx.ui.notify(COMMAND_COMPLETIONS.map((o) => `${o.value} — ${o.description}`).join("\n"), "info");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tokens = [...new Set(sub.split(/\s+/).filter(Boolean))];
|
|
156
|
+
const knownFlags = new Set(COMMAND_COMPLETIONS.map((o) => o.value));
|
|
157
|
+
const unknown = tokens.filter((t) => !knownFlags.has(t));
|
|
158
|
+
if (unknown.length > 0) {
|
|
159
|
+
ctx.ui.notify(
|
|
160
|
+
`Unknown option(s): ${unknown.join(", ")}. Run /checks with no arguments to see the available options.`,
|
|
161
|
+
"warning",
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── /checks --config ───────────────────────────
|
|
167
|
+
if (tokens.includes("--config")) {
|
|
168
|
+
if (tokens.length > 1) {
|
|
169
|
+
ctx.ui.notify("--config cannot be combined with other options.", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await runConfigFlow(ctx);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Flags are additive — any combination of role flags / --review / --all /
|
|
177
|
+
// --gap-finder may be passed together (e.g. "--tests --gap-finder" runs
|
|
178
|
+
// just the tests reviewer plus the gap-finder/filter stages).
|
|
179
|
+
const roleKeys = new Set<ReviewerRole["key"]>();
|
|
180
|
+
if (tokens.includes("--all") || tokens.includes("--review")) {
|
|
181
|
+
for (const role of ROLES) roleKeys.add(role.key);
|
|
182
|
+
}
|
|
183
|
+
for (const role of ROLES) {
|
|
184
|
+
if (tokens.includes(`--${role.key}`)) roleKeys.add(role.key);
|
|
185
|
+
}
|
|
186
|
+
const runGapStages = tokens.includes("--all") || tokens.includes("--gap-finder");
|
|
187
|
+
const activeRoles = ROLES.filter((r) => roleKeys.has(r.key));
|
|
188
|
+
const runReviewers = activeRoles.length > 0;
|
|
189
|
+
|
|
190
|
+
if (!runReviewers && !runGapStages) {
|
|
191
|
+
ctx.ui.notify(
|
|
192
|
+
`Nothing to run for: ${tokens.join(" ")}. Run /checks with no arguments to see the available options.`,
|
|
193
|
+
"warning",
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const runLabel = tokens.join(" ");
|
|
198
|
+
|
|
199
|
+
if (isReviewInProgress()) {
|
|
200
|
+
ctx.ui.notify("A checks run is already in progress.", "warning");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const config = loadReviewConfig();
|
|
205
|
+
if (!config) {
|
|
206
|
+
ctx.ui.notify("No reviewer model configured. Run /checks --config first.", "error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const model = ctx.modelRegistry.find(config.provider, config.modelId);
|
|
211
|
+
if (!model) {
|
|
212
|
+
ctx.ui.notify(
|
|
213
|
+
`Configured model ${config.provider}/${config.modelId} not found. Run /checks --config again.`,
|
|
214
|
+
"error",
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
219
|
+
ctx.ui.notify(`No auth configured for ${config.provider}/${config.modelId}.`, "error");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const missing = REQUIRED_FILES.filter((f) => !existsSync(join(ctx.cwd, f)));
|
|
224
|
+
if (missing.length > 0) {
|
|
225
|
+
ctx.ui.notify(`Missing required file(s) in project root: ${missing.join(", ")}`, "error");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const reviewAbort = startReview();
|
|
230
|
+
// Stage count depends on which flags were passed: however many focus
|
|
231
|
+
// reviewers were selected, plus the gap-finder + gap-filter stages if requested.
|
|
232
|
+
const TOTAL_STAGES = (runReviewers ? activeRoles.length : 0) + (runGapStages ? 2 : 0);
|
|
233
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("preparing clean snapshot", 0, TOTAL_STAGES));
|
|
234
|
+
ctx.ui.notify(`checks (${runLabel}) started. Press ${CANCEL_SHORTCUT_LABEL} to cancel.`, "info");
|
|
235
|
+
|
|
236
|
+
let tempDir: string | undefined;
|
|
237
|
+
try {
|
|
238
|
+
// Unique per-run directory (UUID) so concurrent runs — even across different
|
|
239
|
+
// projects/sessions — never collide on the same temp path.
|
|
240
|
+
const dir = join(tmpdir(), `checks-${randomUUID()}`);
|
|
241
|
+
mkdirSync(dir, { recursive: true });
|
|
242
|
+
tempDir = dir;
|
|
243
|
+
|
|
244
|
+
const snapshot = await snapshotGitHead(pi, ctx.cwd, dir);
|
|
245
|
+
if (snapshot.status === "error") {
|
|
246
|
+
ctx.ui.notify(`checks: ${snapshot.error}`, "error");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const f of REQUIRED_FILES) {
|
|
251
|
+
copyFileSync(join(ctx.cwd, f), join(dir, f));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sections = loadGuidelinesSections();
|
|
255
|
+
const fairnessRules = loadFairnessRules();
|
|
256
|
+
let completed = 0;
|
|
257
|
+
|
|
258
|
+
let reports: ReviewReport[] = [];
|
|
259
|
+
if (runReviewers) {
|
|
260
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("reviewing", completed, TOTAL_STAGES));
|
|
261
|
+
reports = await Promise.all(
|
|
262
|
+
activeRoles.map((role) =>
|
|
263
|
+
runReviewer({
|
|
264
|
+
pi,
|
|
265
|
+
role,
|
|
266
|
+
tempDir: dir,
|
|
267
|
+
model,
|
|
268
|
+
thinkingLevel: config.thinkingLevel,
|
|
269
|
+
rubric: sections[role.key],
|
|
270
|
+
fairnessRules,
|
|
271
|
+
cancelSignal: reviewAbort.signal,
|
|
272
|
+
}).then((report) => {
|
|
273
|
+
completed += 1;
|
|
274
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("reviewing", completed, TOTAL_STAGES));
|
|
275
|
+
return report;
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (reviewAbort.signal.aborted) {
|
|
281
|
+
ctx.ui.notify("checks: cancelled.", "warning");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Test-gap analysis: a research agent proposes candidate behavioral
|
|
287
|
+
// gaps (required-but-untested edge cases that could let an incorrect
|
|
288
|
+
// solution slip past test.patch), then a strict, independent filter
|
|
289
|
+
// agent verifies each one against the fairness rules before it's kept.
|
|
290
|
+
// This never turns a PASS into a FAIL — it only annotates the report.
|
|
291
|
+
let gapFinding: GapStageResult<TestGapCandidate> = { status: "ok", gaps: [] };
|
|
292
|
+
let gapFiltering: GapStageResult<TestGapFinal> = { status: "ok", gaps: [] };
|
|
293
|
+
if (runGapStages) {
|
|
294
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("finding test gaps", completed, TOTAL_STAGES));
|
|
295
|
+
gapFinding = await runGapFinder({
|
|
296
|
+
tempDir: dir,
|
|
297
|
+
model,
|
|
298
|
+
thinkingLevel: config.thinkingLevel,
|
|
299
|
+
testRubric: sections.tests,
|
|
300
|
+
fairnessRules,
|
|
301
|
+
cancelSignal: reviewAbort.signal,
|
|
302
|
+
});
|
|
303
|
+
completed += 1;
|
|
304
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("finding test gaps", completed, TOTAL_STAGES));
|
|
305
|
+
|
|
306
|
+
if (reviewAbort.signal.aborted) {
|
|
307
|
+
ctx.ui.notify("checks: cancelled.", "warning");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (gapFinding.status === "ok" && gapFinding.gaps.length > 0) {
|
|
312
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("validating test gaps", completed, TOTAL_STAGES));
|
|
313
|
+
gapFiltering = await runGapValidator({
|
|
314
|
+
tempDir: dir,
|
|
315
|
+
model,
|
|
316
|
+
thinkingLevel: config.thinkingLevel,
|
|
317
|
+
testRubric: sections.tests,
|
|
318
|
+
fairnessRules,
|
|
319
|
+
candidates: gapFinding.gaps,
|
|
320
|
+
cancelSignal: reviewAbort.signal,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
completed += 1;
|
|
324
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("validating test gaps", completed, TOTAL_STAGES));
|
|
325
|
+
|
|
326
|
+
if (reviewAbort.signal.aborted) {
|
|
327
|
+
ctx.ui.notify("checks: cancelled.", "warning");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, renderProgressLines("finalizing report", TOTAL_STAGES, TOTAL_STAGES));
|
|
333
|
+
|
|
334
|
+
// Each reviewer's structured report lives under its own key so per-agent
|
|
335
|
+
// detail is always preserved. Test gaps never affect `overall` — they're
|
|
336
|
+
// supplementary, filtered notes.
|
|
337
|
+
const byRole: Record<string, ReviewReport> = {};
|
|
338
|
+
activeRoles.forEach((role, i) => {
|
|
339
|
+
if (reports[i]) byRole[role.key] = reports[i];
|
|
340
|
+
});
|
|
341
|
+
const testGaps = gapFiltering.gaps;
|
|
342
|
+
const gapAnalysisIncomplete = runGapStages && (gapFinding.status !== "ok" || gapFiltering.status !== "ok");
|
|
343
|
+
|
|
344
|
+
// Merge into any existing shipd_report.json instead of clobbering it —
|
|
345
|
+
// running --review/--description/--tests/--solution/--gap-finder
|
|
346
|
+
// separately (in any order) should build up one combined report rather
|
|
347
|
+
// than each overwriting the others' results.
|
|
348
|
+
const reportPath = join(ctx.cwd, "shipd_report.json");
|
|
349
|
+
const existingReport = loadExistingReport(reportPath);
|
|
350
|
+
const merged = mergeReport({
|
|
351
|
+
existingReport,
|
|
352
|
+
config,
|
|
353
|
+
runReviewers,
|
|
354
|
+
byRole,
|
|
355
|
+
runGapStages,
|
|
356
|
+
testGaps,
|
|
357
|
+
gapAnalysisIncomplete,
|
|
358
|
+
gapFinderStatus: gapFinding.status,
|
|
359
|
+
gapFilterStatus: gapFiltering.status,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const summary = buildRunSummary({ merged, runReviewers, activeRoles, byRole, runGapStages });
|
|
363
|
+
pi.sendMessage({
|
|
364
|
+
customType: "shipd_checks_report",
|
|
365
|
+
content: summary.content,
|
|
366
|
+
display: true,
|
|
367
|
+
details: summary.details,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
writeFileSync(reportPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
371
|
+
ctx.ui.notify(`checks: wrote details to ${reportPath}`, "info");
|
|
372
|
+
} catch (err) {
|
|
373
|
+
ctx.ui.notify(`checks failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
374
|
+
} finally {
|
|
375
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, undefined);
|
|
376
|
+
if (tempDir) {
|
|
377
|
+
try {
|
|
378
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
379
|
+
} catch {
|
|
380
|
+
// best effort cleanup
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
endReview();
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global config (~/.pi/agent/checks-config.json) for the reviewer model +
|
|
3
|
+
* thinking level, plus helpers for reading pi's own settings.json (enabled
|
|
4
|
+
* models, shell path) that the config flow and git snapshot step need.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ChecksConfig, ThinkingLevel } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const CONFIG_PATH = join(getAgentDir(), "checks-config.json");
|
|
13
|
+
export const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mirrors `getSupportedThinkingLevels` from `@earendil-works/pi-ai` (not part of
|
|
17
|
+
* pi-coding-agent's public export surface, so re-implemented here): a level is
|
|
18
|
+
* available if the model supports reasoning at all, isn't explicitly mapped to
|
|
19
|
+
* `null` in `thinkingLevelMap`, and — for the opt-in `xhigh`/`max` tiers — is
|
|
20
|
+
* explicitly present (non-undefined) in that map.
|
|
21
|
+
*/
|
|
22
|
+
const EXTENDED_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
23
|
+
|
|
24
|
+
export function getSupportedThinkingLevels(
|
|
25
|
+
model: { reasoning?: boolean; thinkingLevelMap?: Record<string, string | null> } | undefined,
|
|
26
|
+
): ThinkingLevel[] {
|
|
27
|
+
if (!model?.reasoning) return ["off"];
|
|
28
|
+
return EXTENDED_THINKING_LEVELS.filter((level) => {
|
|
29
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
30
|
+
if (mapped === null) return false;
|
|
31
|
+
if (level === "xhigh" || level === "max") return mapped !== undefined;
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadReviewConfig(): ChecksConfig | null {
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
39
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as ChecksConfig;
|
|
40
|
+
if (parsed.provider && parsed.modelId && parsed.thinkingLevel) return parsed;
|
|
41
|
+
} catch {
|
|
42
|
+
// fall through
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveReviewConfig(config: ChecksConfig): void {
|
|
48
|
+
const dir = dirname(CONFIG_PATH);
|
|
49
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
50
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadEnabledModelRefs(): string[] {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(SETTINGS_PATH)) return [];
|
|
56
|
+
const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as { enabledModels?: string[] };
|
|
57
|
+
return settings.enabledModels ?? [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function splitProviderModel(ref: string): { provider: string; modelId: string } | null {
|
|
64
|
+
const idx = ref.indexOf("/");
|
|
65
|
+
if (idx <= 0 || idx === ref.length - 1) return null;
|
|
66
|
+
return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getAgentSettings(): { shellPath?: string } {
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
72
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as { shellPath?: string };
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// ignore
|
|
76
|
+
}
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getShellExecutable(): string {
|
|
81
|
+
const fromSettings = getAgentSettings().shellPath;
|
|
82
|
+
if (fromSettings) return fromSettings;
|
|
83
|
+
return process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "bash";
|
|
84
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Clean, non-mutating git HEAD snapshot into a scratch directory. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getShellExecutable } from "./config.js";
|
|
5
|
+
|
|
6
|
+
function toSlashPath(p: string): string {
|
|
7
|
+
return p.replace(/\\/g, "/");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bashQuote(value: string): string {
|
|
11
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function snapshotGitHead(
|
|
15
|
+
pi: ExtensionAPI,
|
|
16
|
+
repoDir: string,
|
|
17
|
+
tempDir: string,
|
|
18
|
+
): Promise<{ status: "ok" } | { status: "error"; error: string }> {
|
|
19
|
+
const headCheck = await pi.exec("git", ["rev-parse", "HEAD"], { cwd: repoDir, timeout: 15_000 });
|
|
20
|
+
if (headCheck.code !== 0) {
|
|
21
|
+
return { status: "error", error: "Not a git repository, or it has no commits yet." };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cmd = `git archive HEAD | tar -x -C ${bashQuote(toSlashPath(tempDir))}`;
|
|
25
|
+
const result = await pi.exec(getShellExecutable(), ["-c", cmd], { cwd: repoDir, timeout: 60_000 });
|
|
26
|
+
if (result.code !== 0) {
|
|
27
|
+
return { status: "error", error: result.stderr?.trim() || `git archive failed (exit ${result.code})` };
|
|
28
|
+
}
|
|
29
|
+
return { status: "ok" };
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipd Checks Extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Strict, parallel 3-agent review of a task's agent_prompt.md, test.patch, and
|
|
5
|
+
* solution.patch against a combined rubric (see rubric.ts): the per-focus
|
|
6
|
+
* P1-P5/T1-T6/S1-S4 checklist and the fairness methodology — agent-fault vs
|
|
7
|
+
* prompt-ambiguity vs test-flaw, fair/unfair test examples. Reviewers only
|
|
8
|
+
* FAIL for genuine blocking issues; everything else is captured as
|
|
9
|
+
* non-blocking notes, mirroring how real shipd reviews mostly surface
|
|
10
|
+
* optional/minor suggestions rather than hard failures.
|
|
11
|
+
*
|
|
12
|
+
* Flow (for whichever stages/roles the chosen option below runs):
|
|
13
|
+
* 1. Snapshot the current git HEAD (no working-dir mutation) into a temp dir
|
|
14
|
+
* via `git archive HEAD | tar -x` (see git.ts).
|
|
15
|
+
* 2. Copy agent_prompt.md, solution.patch, test.patch from the project root
|
|
16
|
+
* into that temp dir.
|
|
17
|
+
* 3. (--all / --review / --description / --tests / --solution) Spawn the
|
|
18
|
+
* selected read-only reviewer agent(s) — description / tests / solution,
|
|
19
|
+
* all 3 in parallel for --all/--review, or just the one requested —
|
|
20
|
+
* each restricted to read/grep/find/ls plus a single
|
|
21
|
+
* `submit_review_report` tool they must call with a structured verdict
|
|
22
|
+
* (see agents.ts, tools.ts).
|
|
23
|
+
* 4. (--all / --gap-finder) Run a sequential 2-stage behavioral test-gap
|
|
24
|
+
* analysis: an exhaustive researcher agent proposes as many candidate
|
|
25
|
+
* gaps as it can find — required-but-untested edge cases that could let
|
|
26
|
+
* an incorrect solution slip past test.patch — then a strict,
|
|
27
|
+
* independent filter agent re-verifies each candidate against the
|
|
28
|
+
* fairness rules and keeps only the ones that hold up. This never turns
|
|
29
|
+
* a PASS into a FAIL; it only annotates the report/summary.
|
|
30
|
+
* 5. Post a one-line chat message and merge results into shipd_report.json
|
|
31
|
+
* in the project root (merged, not overwritten — running any of
|
|
32
|
+
* --review/--description/--tests/--solution/--gap-finder separately, in
|
|
33
|
+
* any order, builds up one combined report; see report.ts). `overall`
|
|
34
|
+
* only reflects a confident PASS/FAIL once all 3 focus reviewers have
|
|
35
|
+
* run at least once; PASS gets "(with test gaps)" appended when the
|
|
36
|
+
* filter stage kept any.
|
|
37
|
+
*
|
|
38
|
+
* Commands (all flags below except --config are additive/combinable, e.g.
|
|
39
|
+
* "/checks --tests --gap-finder" runs just the tests reviewer plus the
|
|
40
|
+
* gap-finder/filter stages; --config must be used alone):
|
|
41
|
+
* /checks list available options (runs nothing)
|
|
42
|
+
* /checks --all run all 3 focus reviewers + test-gap analysis
|
|
43
|
+
* /checks --review run only the 3 focus reviewer agents
|
|
44
|
+
* /checks --description run only the problem-description (prompt) reviewer
|
|
45
|
+
* /checks --tests run only the tests reviewer
|
|
46
|
+
* /checks --solution run only the solution reviewer
|
|
47
|
+
* /checks --gap-finder run only the test-gap finder/filter agents
|
|
48
|
+
* /checks --config set the reviewer model and thinking level
|
|
49
|
+
* Shortcut: Ctrl+Shift+X cancels an in-progress /checks run.
|
|
50
|
+
*
|
|
51
|
+
* File layout:
|
|
52
|
+
* index.ts extension entry point (this file) — renderer, shortcut, command registration
|
|
53
|
+
* command.ts /checks command: arg parsing, --config flow, run orchestration
|
|
54
|
+
* agents.ts spawns/races the reviewer + gap-finder/validator agent sessions
|
|
55
|
+
* prompts.ts all prompt text sent to those agents
|
|
56
|
+
* tools.ts custom tools agents call to submit structured results
|
|
57
|
+
* rubric.ts embedded guidelines/fairness rubric text + section loaders
|
|
58
|
+
* roles.ts the 3 reviewer roles (description/tests/solution) metadata
|
|
59
|
+
* report.ts shipd_report.json load/merge/summary logic
|
|
60
|
+
* config.ts ~/.pi/agent/checks-config.json + settings.json helpers
|
|
61
|
+
* git.ts clean git HEAD snapshot into a scratch directory
|
|
62
|
+
* progress.ts progress-bar widget rendering
|
|
63
|
+
* state.ts shared "run in progress" / cancel state
|
|
64
|
+
* types.ts shared TypeScript types
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
68
|
+
import { Key, Text } from "@earendil-works/pi-tui";
|
|
69
|
+
import { registerChecksCommand } from "./command.js";
|
|
70
|
+
import { PROGRESS_WIDGET_KEY } from "./progress.js";
|
|
71
|
+
import { cancelReview, isReviewInProgress } from "./state.js";
|
|
72
|
+
import type { Verdict } from "./types.js";
|
|
73
|
+
|
|
74
|
+
const CANCEL_SHORTCUT = Key.ctrlShift("x");
|
|
75
|
+
|
|
76
|
+
export default function shipdChecksExtension(pi: ExtensionAPI) {
|
|
77
|
+
pi.registerMessageRenderer<{
|
|
78
|
+
overall?: Verdict;
|
|
79
|
+
hasTestGaps?: boolean;
|
|
80
|
+
gapsCount?: number;
|
|
81
|
+
roleVerdicts?: Record<string, Verdict>;
|
|
82
|
+
showGaps?: boolean;
|
|
83
|
+
}>("shipd_checks_report", (message, _options, theme) => {
|
|
84
|
+
const details = message.details ?? {};
|
|
85
|
+
const colorVerdict = (verdict: Verdict) =>
|
|
86
|
+
verdict === "PASS" ? theme.fg("success", verdict) : theme.fg("error", verdict);
|
|
87
|
+
const segments: string[] = [];
|
|
88
|
+
|
|
89
|
+
// Whichever reviewer(s) just ran, even if `overall` is still incomplete
|
|
90
|
+
// (e.g. only --tests has run so far) — a partial run must always show its
|
|
91
|
+
// own PASS/FAIL, not just silently defer to the gap-count message.
|
|
92
|
+
if (details.roleVerdicts) {
|
|
93
|
+
for (const [role, verdict] of Object.entries(details.roleVerdicts)) {
|
|
94
|
+
segments.push(`${role}: ${colorVerdict(verdict)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (details.overall) {
|
|
99
|
+
const suffix = details.overall === "PASS" && details.hasTestGaps ? " (with test gaps)" : "";
|
|
100
|
+
segments.push(`Overall: ${colorVerdict(details.overall)}${suffix}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (details.showGaps) {
|
|
104
|
+
const count = details.gapsCount ?? 0;
|
|
105
|
+
segments.push(
|
|
106
|
+
count > 0 ? theme.fg("warning", `${count} test gap(s) found`) : theme.fg("success", "no test gaps found"),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const text = segments.length > 0 ? segments.join(" ") : theme.fg("dim", "nothing to report");
|
|
111
|
+
return new Text(`${theme.bold("Checks: ")}${text}`, 0, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerShortcut(CANCEL_SHORTCUT, {
|
|
115
|
+
description: "Cancel an in-progress /checks run",
|
|
116
|
+
handler: (ctx) => {
|
|
117
|
+
if (!isReviewInProgress()) return;
|
|
118
|
+
if (!cancelReview()) return;
|
|
119
|
+
ctx.ui.setWidget(PROGRESS_WIDGET_KEY, [`checks: cancelling (${CANCEL_SHORTCUT})...`]);
|
|
120
|
+
ctx.ui.notify("checks: cancelling...", "warning");
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
registerChecksCommand(pi);
|
|
125
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Text-based progress bar rendered via ctx.ui.setWidget while a run is in flight. */
|
|
2
|
+
|
|
3
|
+
export const PROGRESS_WIDGET_KEY = "checks_progress";
|
|
4
|
+
const PROGRESS_BAR_WIDTH = 24;
|
|
5
|
+
|
|
6
|
+
export function renderProgressLines(label: string, done: number, total: number): string[] {
|
|
7
|
+
const ratio = total > 0 ? Math.min(1, done / total) : 0;
|
|
8
|
+
const filled = Math.round(PROGRESS_BAR_WIDTH * ratio);
|
|
9
|
+
const bar = "█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled));
|
|
10
|
+
return [`checks: ${label} [${bar}] ${done}/${total}`];
|
|
11
|
+
}
|