@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/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
+ }
@@ -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
+ }