@inceptionstack/pi-hard-no 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.
package/settings.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * settings.ts — Configuration loading and validation
3
+ *
4
+ * Loads config from .hardno/ in two locations (local takes precedence):
5
+ * 1. cwd/.hardno/ (project-local)
6
+ * 2. ~/.pi/.hardno/ (global)
7
+ *
8
+ * Files: settings.json, review-rules.md
9
+ */
10
+
11
+ import { readFile } from "node:fs/promises";
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ /**
17
+ * Resolve the .hardno config directory paths.
18
+ * Returns [local, global] where local = cwd/.hardno, global = ~/.pi/.hardno.
19
+ * Local takes precedence over global.
20
+ */
21
+ export function configDirs(cwd: string, home?: string): [string, string] {
22
+ return [join(cwd, ".hardno"), join(home ?? homedir(), ".pi", ".hardno")];
23
+ }
24
+
25
+ /**
26
+ * Read a config file, trying local (cwd/.hardno/) first, then global (~/.pi/.hardno/).
27
+ * Returns the file content or null if not found in either location.
28
+ */
29
+ export async function readConfigFile(
30
+ cwd: string,
31
+ filename: string,
32
+ home?: string,
33
+ ): Promise<string | null> {
34
+ for (const dir of configDirs(cwd, home)) {
35
+ try {
36
+ return await readFile(join(dir, filename), "utf8");
37
+ } catch {
38
+ /* try next */
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Synchronous version of readConfigFile for init-time use.
46
+ */
47
+ function readConfigFileSync(cwd: string, filename: string): string | null {
48
+ for (const dir of configDirs(cwd)) {
49
+ try {
50
+ return readFileSync(join(dir, filename), "utf8");
51
+ } catch {
52
+ /* try next */
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ── Types ────────────────────────────────────────────
59
+
60
+ export interface AutoReviewSettings {
61
+ maxReviewLoops: number;
62
+ model: string; // "provider/model-id" e.g. "amazon-bedrock/us.anthropic.claude-opus-4-6-v1"
63
+ thinkingLevel: string; // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
64
+ architectEnabled: boolean;
65
+ reviewTimeoutMs: number; // Max wall-clock for a single review (default 120s)
66
+ toggleShortcut: string; // Key id for toggling review on/off (default "alt+r")
67
+ cancelShortcut: string; // Key id for cancelling in-progress review (default: none — use /cancel-review)
68
+ /** Duplicate-review suppressor ("judge") — see judge.ts. Off by default so
69
+ * users opt in. When enabled and all bash commands in a turn classify as
70
+ * read-only, the auto-review is skipped entirely. */
71
+ judgeEnabled: boolean;
72
+ /** Model used by the judge. Chosen from `eval/RESULTS.md`. */
73
+ judgeModel: string;
74
+ /** Max wall-clock per judge classification call (default 10s). */
75
+ judgeTimeoutMs: number;
76
+ }
77
+
78
+ /** Shortcut-only settings loaded synchronously at init (before session_start). */
79
+ export interface ShortcutSettings {
80
+ toggleShortcut: string;
81
+ cancelShortcut: string;
82
+ }
83
+
84
+ export const DEFAULT_TOGGLE_SHORTCUT = "alt+r";
85
+ export const DEFAULT_CANCEL_SHORTCUT = ""; // no default shortcut — use /cancel-review command
86
+
87
+ export const DEFAULT_SETTINGS: AutoReviewSettings = {
88
+ maxReviewLoops: 100,
89
+ model: "amazon-bedrock/us.anthropic.claude-opus-4-6-v1",
90
+ thinkingLevel: "off",
91
+ architectEnabled: true, // triggers when >1 file reviewed
92
+ reviewTimeoutMs: 120_000,
93
+ toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
94
+ cancelShortcut: DEFAULT_CANCEL_SHORTCUT,
95
+ judgeEnabled: false,
96
+ judgeModel: "amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
97
+ judgeTimeoutMs: 10_000,
98
+ };
99
+
100
+ export const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
101
+
102
+ // ── Parsing ──────────────────────────────────────────
103
+
104
+ /**
105
+ * Parse and validate a raw settings object against the schema.
106
+ * Pure function — no I/O. Returns validated settings + any errors.
107
+ */
108
+ export function parseSettings(parsed: Record<string, unknown>): {
109
+ settings: AutoReviewSettings;
110
+ errors: string[];
111
+ } {
112
+ const errors: string[] = [];
113
+ const settings = { ...DEFAULT_SETTINGS };
114
+
115
+ if ("maxReviewLoops" in parsed) {
116
+ if (
117
+ typeof parsed.maxReviewLoops === "number" &&
118
+ Number.isInteger(parsed.maxReviewLoops) &&
119
+ parsed.maxReviewLoops > 0
120
+ ) {
121
+ settings.maxReviewLoops = parsed.maxReviewLoops;
122
+ } else {
123
+ errors.push(
124
+ `[hard-no] "maxReviewLoops" must be a positive integer (got ${JSON.stringify(parsed.maxReviewLoops)}). Using default: ${DEFAULT_SETTINGS.maxReviewLoops}.`,
125
+ );
126
+ }
127
+ }
128
+
129
+ if ("model" in parsed) {
130
+ if (typeof parsed.model === "string" && parsed.model.includes("/")) {
131
+ settings.model = parsed.model;
132
+ } else {
133
+ errors.push(
134
+ `[hard-no] "model" must be "provider/model-id" (got ${JSON.stringify(parsed.model)}). Using default: ${DEFAULT_SETTINGS.model}.`,
135
+ );
136
+ }
137
+ }
138
+
139
+ if ("thinkingLevel" in parsed) {
140
+ if (
141
+ typeof parsed.thinkingLevel === "string" &&
142
+ VALID_THINKING_LEVELS.includes(parsed.thinkingLevel)
143
+ ) {
144
+ settings.thinkingLevel = parsed.thinkingLevel;
145
+ } else {
146
+ errors.push(
147
+ `[hard-no] "thinkingLevel" must be one of ${VALID_THINKING_LEVELS.join(", ")} (got ${JSON.stringify(parsed.thinkingLevel)}). Using default: ${DEFAULT_SETTINGS.thinkingLevel}.`,
148
+ );
149
+ }
150
+ }
151
+
152
+ if ("architectEnabled" in parsed) {
153
+ if (typeof parsed.architectEnabled === "boolean") {
154
+ settings.architectEnabled = parsed.architectEnabled;
155
+ } else {
156
+ errors.push(
157
+ `[hard-no] "architectEnabled" must be a boolean (got ${JSON.stringify(parsed.architectEnabled)}). Using default: ${DEFAULT_SETTINGS.architectEnabled}.`,
158
+ );
159
+ }
160
+ }
161
+
162
+ // Backwards compat: accept old "roundupEnabled" if "architectEnabled" not set
163
+ if (!("architectEnabled" in parsed) && "roundupEnabled" in parsed) {
164
+ if (typeof parsed.roundupEnabled === "boolean") {
165
+ settings.architectEnabled = parsed.roundupEnabled;
166
+ } else {
167
+ errors.push(
168
+ `[hard-no] "roundupEnabled" must be a boolean (got ${JSON.stringify(parsed.roundupEnabled)}). Using default: ${DEFAULT_SETTINGS.architectEnabled}.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ if ("reviewTimeoutMs" in parsed) {
174
+ if (
175
+ typeof parsed.reviewTimeoutMs === "number" &&
176
+ Number.isInteger(parsed.reviewTimeoutMs) &&
177
+ parsed.reviewTimeoutMs > 0
178
+ ) {
179
+ settings.reviewTimeoutMs = parsed.reviewTimeoutMs;
180
+ } else {
181
+ errors.push(
182
+ `[hard-no] "reviewTimeoutMs" must be a positive integer (got ${JSON.stringify(parsed.reviewTimeoutMs)}). Using default: ${DEFAULT_SETTINGS.reviewTimeoutMs}.`,
183
+ );
184
+ }
185
+ }
186
+
187
+ if ("toggleShortcut" in parsed) {
188
+ if (typeof parsed.toggleShortcut === "string" && parsed.toggleShortcut.trim()) {
189
+ settings.toggleShortcut = parsed.toggleShortcut.trim();
190
+ } else {
191
+ errors.push(
192
+ `[hard-no] "toggleShortcut" must be a non-empty string key id (got ${JSON.stringify(parsed.toggleShortcut)}). Using default: ${DEFAULT_SETTINGS.toggleShortcut}.`,
193
+ );
194
+ }
195
+ }
196
+
197
+ if ("cancelShortcut" in parsed) {
198
+ if (typeof parsed.cancelShortcut === "string") {
199
+ // Empty string is valid — means "no shortcut" (use /cancel-review command instead)
200
+ settings.cancelShortcut = parsed.cancelShortcut.trim();
201
+ } else {
202
+ errors.push(
203
+ `[hard-no] "cancelShortcut" must be a string key id (got ${JSON.stringify(parsed.cancelShortcut)}). Using default: ${DEFAULT_SETTINGS.cancelShortcut}.`,
204
+ );
205
+ }
206
+ }
207
+
208
+ if ("judgeEnabled" in parsed) {
209
+ if (typeof parsed.judgeEnabled === "boolean") {
210
+ settings.judgeEnabled = parsed.judgeEnabled;
211
+ } else {
212
+ errors.push(
213
+ `[hard-no] "judgeEnabled" must be a boolean (got ${JSON.stringify(parsed.judgeEnabled)}). Using default: ${DEFAULT_SETTINGS.judgeEnabled}.`,
214
+ );
215
+ }
216
+ }
217
+
218
+ if ("judgeModel" in parsed) {
219
+ if (typeof parsed.judgeModel === "string" && parsed.judgeModel.includes("/")) {
220
+ settings.judgeModel = parsed.judgeModel;
221
+ } else {
222
+ errors.push(
223
+ `[hard-no] "judgeModel" must be "provider/model-id" (got ${JSON.stringify(parsed.judgeModel)}). Using default: ${DEFAULT_SETTINGS.judgeModel}.`,
224
+ );
225
+ }
226
+ }
227
+
228
+ if ("judgeTimeoutMs" in parsed) {
229
+ if (
230
+ typeof parsed.judgeTimeoutMs === "number" &&
231
+ Number.isInteger(parsed.judgeTimeoutMs) &&
232
+ parsed.judgeTimeoutMs > 0
233
+ ) {
234
+ settings.judgeTimeoutMs = parsed.judgeTimeoutMs;
235
+ } else {
236
+ errors.push(
237
+ `[hard-no] "judgeTimeoutMs" must be a positive integer (got ${JSON.stringify(parsed.judgeTimeoutMs)}). Using default: ${DEFAULT_SETTINGS.judgeTimeoutMs}.`,
238
+ );
239
+ }
240
+ }
241
+
242
+ const knownKeys = new Set(Object.keys(DEFAULT_SETTINGS));
243
+ // Accept legacy "roundupEnabled" without warning
244
+ knownKeys.add("roundupEnabled");
245
+ for (const key of Object.keys(parsed)) {
246
+ if (!knownKeys.has(key)) {
247
+ errors.push(
248
+ `[hard-no] Unknown setting "${key}" (ignored). Known: ${[...knownKeys].join(", ")}.`,
249
+ );
250
+ }
251
+ }
252
+
253
+ return { settings, errors };
254
+ }
255
+
256
+ // ── File loaders ─────────────────────────────────────
257
+
258
+ /**
259
+ * Load and validate .hardno/settings.json.
260
+ * Tries cwd/.hardno/ first, then ~/.pi/.hardno/.
261
+ */
262
+ export async function loadSettings(
263
+ cwd: string,
264
+ ): Promise<{ settings: AutoReviewSettings; errors: string[] }> {
265
+ const errors: string[] = [];
266
+
267
+ const raw = await readConfigFile(cwd, "settings.json");
268
+ if (raw === null) return { settings: { ...DEFAULT_SETTINGS }, errors };
269
+
270
+ let parsed: any;
271
+ try {
272
+ parsed = JSON.parse(raw);
273
+ } catch (e: any) {
274
+ errors.push(`[hard-no] .hardno/settings.json is not valid JSON: ${e.message}. Using defaults.`);
275
+ return { settings: { ...DEFAULT_SETTINGS }, errors };
276
+ }
277
+
278
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
279
+ errors.push(`[hard-no] .hardno/settings.json must be a JSON object. Using defaults.`);
280
+ return { settings: { ...DEFAULT_SETTINGS }, errors };
281
+ }
282
+
283
+ const result = parseSettings(parsed);
284
+ return { settings: result.settings, errors: [...errors, ...result.errors] };
285
+ }
286
+
287
+ /**
288
+ * Synchronously load shortcut settings from .hardno/settings.json.
289
+ * Tries cwd/.hardno/ first, then ~/.pi/.hardno/.
290
+ * Used at extension init time (before session_start) for shortcut registration.
291
+ * Falls back to defaults on any error — never throws.
292
+ */
293
+ export function loadShortcutSettingsSync(cwd: string): ShortcutSettings {
294
+ const defaults: ShortcutSettings = {
295
+ toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
296
+ cancelShortcut: DEFAULT_CANCEL_SHORTCUT,
297
+ };
298
+ try {
299
+ const raw = readConfigFileSync(cwd, "settings.json");
300
+ if (raw === null) return defaults;
301
+ const parsed = JSON.parse(raw);
302
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return defaults;
303
+ if (typeof parsed.toggleShortcut === "string" && parsed.toggleShortcut.trim()) {
304
+ defaults.toggleShortcut = parsed.toggleShortcut.trim();
305
+ }
306
+ if (typeof parsed.cancelShortcut === "string" && parsed.cancelShortcut.trim()) {
307
+ defaults.cancelShortcut = parsed.cancelShortcut.trim();
308
+ }
309
+ } catch {
310
+ /* bad JSON — use defaults */
311
+ }
312
+ return defaults;
313
+ }
314
+
315
+ /**
316
+ * Load .hardno/review-rules.md custom review rules.
317
+ * Tries cwd/.hardno/ first, then ~/.pi/.hardno/.
318
+ */
319
+ export async function loadReviewRules(cwd: string): Promise<string | null> {
320
+ const content = await readConfigFile(cwd, "review-rules.md");
321
+ return content?.trim() || null;
322
+ }
323
+
324
+ /**
325
+ * Load .hardno/auto-review.md — overrides the "what to review / what not to report"
326
+ * section of the review prompt. Returns null if not found (uses built-in defaults).
327
+ * Tries cwd/.hardno/ first, then ~/.pi/.hardno/.
328
+ */
329
+ export async function loadAutoReviewRules(cwd: string): Promise<string | null> {
330
+ const content = await readConfigFile(cwd, "auto-review.md");
331
+ return content?.trim() || null;
332
+ }