@unifan/pi-review-zh 1.0.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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Parse `/review` command arguments.
3
+ *
4
+ * Surface is intentionally minimal:
5
+ * - `--lite` fast single-agent review (no fan-out, no gate)
6
+ * - `--no-spawn` hidden dry-run (prints the resolved plan, no subprocess)
7
+ * - trailing freeform text → user review request (focus / requirements /
8
+ * PR url / context), injected into reviewers and gate via target.userContext
9
+ * (see src/prep.ts and src/git-input.ts)
10
+ *
11
+ * Removed flags (--threshold / --reviewer / --score-per-issue / --diff) are
12
+ * accepted-but-ignored for graceful degradation of old invocations: a valued
13
+ * legacy flag also consumes its next token so its value does not leak into
14
+ * `input`. Those capabilities now live in config.json (`/review-config`).
15
+ * `--gate-model <id>` remains an active per-run override.
16
+ */
17
+
18
+ export interface ParsedReviewArgs {
19
+ /** Freeform user request: review focus, PR url, or context. */
20
+ input?: string;
21
+ /** Dry-run: print resolved plan without spawning. */
22
+ noSpawn: boolean;
23
+ /** Single-agent fast mode: one reviewer, no gate. */
24
+ lite: boolean;
25
+ /** Override the gate model for this run (otherwise config.gate.model). */
26
+ gateModel?: string;
27
+ }
28
+
29
+ /** Removed valued flags — silently skip flag + value to keep input clean. */
30
+ const LEGACY_VALUED_FLAGS = new Set([
31
+ "--threshold",
32
+ "--reviewer",
33
+ "--score-per-issue",
34
+ "--diff",
35
+ ]);
36
+
37
+ export function parseReviewArgs(raw: string): ParsedReviewArgs {
38
+ const tokens = tokenize(raw);
39
+ const result: ParsedReviewArgs = { noSpawn: false, lite: false };
40
+ const inputParts: string[] = [];
41
+
42
+ for (let i = 0; i < tokens.length; i++) {
43
+ const t = tokens[i];
44
+ if (LEGACY_VALUED_FLAGS.has(t)) {
45
+ i++; // consume the value too
46
+ continue;
47
+ }
48
+ if (t === "--no-spawn") {
49
+ result.noSpawn = true;
50
+ continue;
51
+ }
52
+ if (t === "--lite") {
53
+ result.lite = true;
54
+ continue;
55
+ }
56
+ if (t === "--gate-model") {
57
+ const id = tokens[++i];
58
+ if (id) result.gateModel = id;
59
+ continue;
60
+ }
61
+ if (t.startsWith("-")) {
62
+ continue; // ignore other legacy / unknown flags
63
+ }
64
+ inputParts.push(t);
65
+ }
66
+
67
+ const input = inputParts.join(" ").trim();
68
+ if (input.length > 0) {
69
+ result.input = input;
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /** Split on whitespace preserving quoted segments. */
76
+ function tokenize(raw: string): string[] {
77
+ const out: string[] = [];
78
+ let cur = "";
79
+ let quote: "'" | '"' | null = null;
80
+ for (let i = 0; i < raw.length; i++) {
81
+ const c = raw[i];
82
+ if (quote) {
83
+ if (c === quote) {
84
+ quote = null;
85
+ } else {
86
+ cur += c;
87
+ }
88
+ continue;
89
+ }
90
+ if (c === "'" || c === '"') {
91
+ quote = c;
92
+ continue;
93
+ }
94
+ if (/\s/.test(c)) {
95
+ if (cur.length > 0) {
96
+ out.push(cur);
97
+ cur = "";
98
+ }
99
+ continue;
100
+ }
101
+ cur += c;
102
+ }
103
+ if (cur.length > 0) out.push(cur);
104
+ return out;
105
+ }
package/src/config.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Configuration loading, validation, and atomic persistence (v0.7).
3
+ *
4
+ * The user-editable config lives at:
5
+ * ~/.pi/agent/extensions/pi-review/config.json
6
+ *
7
+ * Pattern mirrors pi-subagents/src/extension/config.ts. We never touch the
8
+ * top-level settings.json — that file is managed by pi itself.
9
+ *
10
+ * v0.7 removes configuration knobs that the foreground workflowScript path
11
+ * cannot honor (per-reviewer `tools`, `inheritance`, `gate.scorePerIssue`,
12
+ * top-level `concurrency`). Legacy keys are still read for migration
13
+ * warnings but no longer drive behavior.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { LEAN_BUDGETS } from "./lean-agents.js";
19
+ import type { PiReviewConfig, ReviewerSpec, RoutingMode, VerdictPolicy } from "./types.js";
20
+
21
+ /**
22
+ * Cheap model used by default for the gate (dedupe + re-score + verdict).
23
+ * The gate is pure de-noise reasoning, so it defaults to a cheap tier;
24
+ * reviewers stay on "inherit" to follow the parent session's stronger model.
25
+ * Override via config.json (`gate.model`) or `--gate-model`.
26
+ */
27
+ export const DEFAULT_GATE_MODEL = "anthropic/claude-haiku-4-5";
28
+
29
+ /** Default reviewer and gate config shipped with the package (v0.7). */
30
+ export const DEFAULT_CONFIG: PiReviewConfig = {
31
+ schemaVersion: 1,
32
+ gate: {
33
+ model: DEFAULT_GATE_MODEL,
34
+ thinking: "low",
35
+ enabled: true,
36
+ threshold: 8,
37
+ verdictPolicy: "strict",
38
+ },
39
+ routing: {
40
+ mode: "adaptive",
41
+ },
42
+ reviewers: {
43
+ "claude-md-compliance": {
44
+ id: "claude-md-compliance",
45
+ label: "Claude-MD Compliance",
46
+ enabled: true,
47
+ model: "inherit",
48
+ },
49
+ bugbot: {
50
+ id: "bugbot",
51
+ label: "Bugbot",
52
+ enabled: true,
53
+ model: "inherit",
54
+ },
55
+ "history-context": {
56
+ id: "history-context",
57
+ label: "History Context",
58
+ enabled: true,
59
+ model: "inherit",
60
+ },
61
+ "security-review": {
62
+ id: "security-review",
63
+ label: "Security Review",
64
+ enabled: true,
65
+ model: "inherit",
66
+ },
67
+ "code-comments": {
68
+ id: "code-comments",
69
+ label: "Code Comments",
70
+ enabled: true,
71
+ model: "inherit",
72
+ },
73
+ conventions: {
74
+ id: "conventions",
75
+ label: "Conventions",
76
+ enabled: false,
77
+ model: "inherit",
78
+ },
79
+ },
80
+ // No budgets override by default — lean-agents' LEAN_BUDGETS owns the
81
+ // defaults (26 turns); a stale hard-coded 20 here would silently regress
82
+ // them whenever config was wired through.
83
+ };
84
+
85
+ /**
86
+ * Canonical config file path — top-level beside `permission-modes.json`,
87
+ * `settings.json`, etc. (mirrors pi-permission-modes), so it is easy to find.
88
+ * Tests override it via `setConfigPath` to point at a sandbox.
89
+ */
90
+ const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-review.json");
91
+ let _configPath = DEFAULT_CONFIG_PATH;
92
+
93
+ export function configPath(): string {
94
+ return _configPath;
95
+ }
96
+
97
+ /** Override the config path (used by tests). Pass nothing to reset to default. */
98
+ export function setConfigPath(p?: string): void {
99
+ _configPath = p ?? DEFAULT_CONFIG_PATH;
100
+ }
101
+
102
+ /**
103
+ * Read raw config from disk. Returns an empty object when the file is
104
+ * missing, unreadable, or corrupt — the caller is expected to fall through
105
+ * to mergeWithDefaults().
106
+ */
107
+ export function loadRawConfig(): Record<string, unknown> {
108
+ const path = configPath();
109
+ if (!existsSync(path)) return {};
110
+ try {
111
+ const text = readFileSync(path, "utf-8");
112
+ const parsed = JSON.parse(text);
113
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
114
+ return parsed as Record<string, unknown>;
115
+ }
116
+ return {};
117
+ } catch {
118
+ return {};
119
+ }
120
+ }
121
+
122
+ /** Legacy config keys that no longer drive behavior (migration warning only). */
123
+ export const LEGACY_CONFIG_KEYS = [
124
+ "concurrency",
125
+ "inheritance",
126
+ "gate.scorePerIssue",
127
+ "reviewers.<id>.tools",
128
+ "reviewers.<id>.timeoutMs",
129
+ ] as const;
130
+
131
+ /** Detect legacy keys in a raw config and return human-readable warnings. */
132
+ export function legacyConfigWarnings(raw: Record<string, unknown>): string[] {
133
+ const warnings: string[] = [];
134
+ if ("concurrency" in raw) {
135
+ warnings.push("concurrency is no longer used (workflowScript runs all reviewers in parallel).");
136
+ }
137
+ if ("inheritance" in raw) {
138
+ warnings.push("inheritance is no longer used (agent tools come from agent frontmatter).");
139
+ }
140
+ const gate = raw.gate as Record<string, unknown> | undefined;
141
+ if (gate && "scorePerIssue" in gate) {
142
+ warnings.push("gate.scorePerIssue is no longer used (gate re-scores within its single pass).");
143
+ }
144
+ const reviewers = raw.reviewers as Record<string, unknown> | undefined;
145
+ if (reviewers && typeof reviewers === "object") {
146
+ for (const [id, ov] of Object.entries(reviewers)) {
147
+ if (!ov || typeof ov !== "object" || Array.isArray(ov)) continue;
148
+ const o = ov as Record<string, unknown>;
149
+ if ("tools" in o) warnings.push(`reviewers.${id}.tools is no longer used (tools come from agent frontmatter).`);
150
+ if ("timeoutMs" in o) warnings.push(`reviewers.${id}.timeoutMs is no longer used; use budgets.turnBudget.`);
151
+ }
152
+ }
153
+ return warnings;
154
+ }
155
+
156
+ /**
157
+ * Deep-merge a raw user config over DEFAULT_CONFIG. We deliberately re-build
158
+ * nested objects rather than mutating so the merge is pure.
159
+ */
160
+ export function mergeWithDefaults(raw: unknown): PiReviewConfig {
161
+ const base: PiReviewConfig = structuredClone(DEFAULT_CONFIG);
162
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
163
+ const r = raw as Record<string, unknown>;
164
+
165
+ if (typeof r.schemaVersion === "number") {
166
+ base.schemaVersion = r.schemaVersion as 1;
167
+ }
168
+
169
+ // Gate block.
170
+ if (r.gate && typeof r.gate === "object" && !Array.isArray(r.gate)) {
171
+ const g = r.gate as Record<string, unknown>;
172
+ if (typeof g.model === "string") base.gate.model = g.model;
173
+ if (typeof g.thinking === "string") base.gate.thinking = g.thinking;
174
+ if (typeof g.enabled === "boolean") base.gate.enabled = g.enabled;
175
+ if (typeof g.threshold === "number" && Number.isFinite(g.threshold)) {
176
+ base.gate.threshold = clampThreshold(g.threshold);
177
+ }
178
+ if (typeof g.verdictPolicy === "string") {
179
+ const vp = parseVerdictPolicy(g.verdictPolicy);
180
+ if (vp) base.gate.verdictPolicy = vp;
181
+ }
182
+ // Legacy: `scorePerIssue` ignored with a migration warning (see
183
+ // legacyConfigWarnings). Explicitly dropped here so it cannot leak.
184
+ }
185
+
186
+ // Routing block.
187
+ if (r.routing && typeof r.routing === "object" && !Array.isArray(r.routing)) {
188
+ const rt = r.routing as Record<string, unknown>;
189
+ if (typeof rt.mode === "string") {
190
+ const mode = parseRoutingMode(rt.mode);
191
+ if (mode) base.routing.mode = mode;
192
+ }
193
+ }
194
+
195
+ // Reviewer overrides — keyed by id. tools/timeoutMs are ignored (legacy).
196
+ if (r.reviewers && typeof r.reviewers === "object" && !Array.isArray(r.reviewers)) {
197
+ const reviewers = r.reviewers as Record<string, unknown>;
198
+ for (const [id, rawR] of Object.entries(reviewers)) {
199
+ if (!rawR || typeof rawR !== "object" || Array.isArray(rawR)) continue;
200
+ const ov = rawR as Record<string, unknown>;
201
+ const existing = base.reviewers[id];
202
+ const merged: ReviewerSpec = existing
203
+ ? { ...existing, id, label: existing.label }
204
+ : { id, label: id, enabled: true, model: "inherit" };
205
+ if (typeof ov.label === "string") merged.label = ov.label;
206
+ if (typeof ov.enabled === "boolean") merged.enabled = ov.enabled;
207
+ if (typeof ov.model === "string") merged.model = ov.model;
208
+ if (typeof ov.thinking === "string") merged.thinking = ov.thinking;
209
+ if (typeof ov.promptPath === "string") merged.promptPath = ov.promptPath;
210
+ base.reviewers[id] = merged;
211
+ }
212
+ }
213
+
214
+ // Optional budgets (directive path). The empty-object fallback mirrors
215
+ // lean-agents' defaults — a hard-coded 20 here would silently regress the
216
+ // 26-turn default whenever a user wrote `budgets: {}`.
217
+ if (r.budgets && typeof r.budgets === "object" && !Array.isArray(r.budgets)) {
218
+ const b = r.budgets as Record<string, unknown>;
219
+ base.budgets = base.budgets ?? {
220
+ turnBudget: { maxTurns: LEAN_BUDGETS.turnBudget.maxTurns, graceTurns: LEAN_BUDGETS.turnBudget.graceTurns },
221
+ };
222
+ if (b.turnBudget && typeof b.turnBudget === "object" && !Array.isArray(b.turnBudget)) {
223
+ const tb = b.turnBudget as Record<string, unknown>;
224
+ base.budgets.turnBudget = {
225
+ ...base.budgets.turnBudget,
226
+ ...(typeof tb.maxTurns === "number" && Number.isFinite(tb.maxTurns)
227
+ ? { maxTurns: Math.max(1, Math.min(48, Math.floor(tb.maxTurns))) }
228
+ : {}),
229
+ ...(typeof tb.graceTurns === "number" && Number.isFinite(tb.graceTurns)
230
+ ? { graceTurns: Math.max(0, Math.floor(tb.graceTurns)) }
231
+ : {}),
232
+ };
233
+ }
234
+ }
235
+
236
+ return base;
237
+ }
238
+
239
+ /** Threshold is 0-10 inclusive; values outside are clamped. NaN falls back to 8 (default). */
240
+ export function clampThreshold(n: number): number {
241
+ if (Number.isNaN(n)) return 8;
242
+ if (n === Infinity) return 10;
243
+ if (n === -Infinity) return 0;
244
+ if (!Number.isFinite(n)) return 8;
245
+ return Math.max(0, Math.min(10, Math.floor(n)));
246
+ }
247
+
248
+ export function parseVerdictPolicy(raw: string): VerdictPolicy | null {
249
+ const v = raw.trim().toLowerCase();
250
+ if (v === "strict" || v === "legacy") return v;
251
+ return null;
252
+ }
253
+
254
+ export function parseRoutingMode(raw: string): RoutingMode | null {
255
+ const v = raw.trim().toLowerCase();
256
+ if (v === "adaptive" || v === "all") return v;
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Validate a merged config. Returns ok=false with a list of human-readable
262
+ * errors when something is wrong. Used by /review-config to surface bad edits.
263
+ */
264
+ export function validateConfig(cfg: PiReviewConfig): { ok: true } | { ok: false; errors: string[] } {
265
+ const errors: string[] = [];
266
+ if (cfg.schemaVersion !== 1) {
267
+ errors.push(`schemaVersion must be 1 (got ${String(cfg.schemaVersion)})`);
268
+ }
269
+ if (cfg.gate.model !== "inherit" && typeof cfg.gate.model !== "string") {
270
+ errors.push("gate.model must be a string or 'inherit'");
271
+ }
272
+ if (cfg.gate.model === "") {
273
+ errors.push("gate.model cannot be an empty string");
274
+ }
275
+ if (cfg.gate.threshold < 0 || cfg.gate.threshold > 10) {
276
+ errors.push("gate.threshold must be between 0 and 10");
277
+ }
278
+ if (cfg.gate.verdictPolicy !== "strict" && cfg.gate.verdictPolicy !== "legacy") {
279
+ errors.push("gate.verdictPolicy must be strict | legacy");
280
+ }
281
+ if (cfg.routing.mode !== "adaptive" && cfg.routing.mode !== "all") {
282
+ errors.push("routing.mode must be adaptive | all");
283
+ }
284
+ for (const [id, r] of Object.entries(cfg.reviewers)) {
285
+ if (r.model !== "inherit" && typeof r.model !== "string") {
286
+ errors.push(`reviewers.${id}.model must be a string or 'inherit'`);
287
+ }
288
+ if (r.model === "") {
289
+ errors.push(`reviewers.${id}.model cannot be an empty string`);
290
+ }
291
+ }
292
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
293
+ }
294
+
295
+ /** Atomic write: tmp file + rename. Mirrors pi-effort/effort.ts:201-218. */
296
+ export function writeConfig(cfg: PiReviewConfig): void {
297
+ const path = configPath();
298
+ mkdirSync(dirname(path), { recursive: true });
299
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
300
+ try {
301
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
302
+ renameSync(tmp, path);
303
+ } catch (err) {
304
+ try {
305
+ unlinkSync(tmp);
306
+ } catch {
307
+ /* ignore */
308
+ }
309
+ throw err;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Read → merge → validate in one call. Returns the effective config plus any
315
+ * validation issues. When validation fails, the function still returns the
316
+ * merged config (best-effort) so the caller can decide whether to proceed.
317
+ */
318
+ export function loadConfig(): { config: PiReviewConfig; errors: string[]; legacyWarnings: string[] } {
319
+ const raw = loadRawConfig();
320
+ const config = mergeWithDefaults(raw);
321
+ const validation = validateConfig(config);
322
+ return {
323
+ config,
324
+ errors: validation.ok ? [] : validation.errors,
325
+ legacyWarnings: legacyConfigWarnings(raw),
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Resolve the "inherit" sentinel against a real model id. Falls back to a
331
+ * sensible default when the parent session has no model (e.g. RPC mode).
332
+ */
333
+ export function resolveModel(value: string | "inherit", parentModelId: string | undefined): string {
334
+ if (value === "inherit") {
335
+ return parentModelId && parentModelId.length > 0
336
+ ? parentModelId
337
+ : DEFAULT_GATE_MODEL;
338
+ }
339
+ return value;
340
+ }