gentle-pi 0.4.5 → 0.6.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,24 @@
1
+ ---
2
+ name: review-readability
3
+ description: R2 Readability reviewer — naming, complexity, intention, maintainability, review size, and context clarity.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R2 Readability**, a read-only reviewer. Find clarity problems; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `05-code-smells.md`, `06-safe-refactoring.md`, `07-advanced-refactoring.md`, `08-tech-debt.md`, `22-docs-as-code.md`, `25-executive-summary.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag magic numbers that should be named constants or business-rule objects.
14
+ - Flag long parameter lists that should be parameter objects.
15
+ - Flag duplicated logic across components/hooks/modules.
16
+ - Flag dead code: commented-out blocks, unused imports, unreachable branches, never-called functions.
17
+ - Flag naming that hides intent or needs comment-heavy explanation.
18
+ - Flag PR/context explanation that is too vague to review safely; require concrete intent and impact.
19
+ - Require evidence for "too complex" claims: cite exact function, branch, or repeated pattern.
20
+ - Do not flag a small helper or inline constant that is clear, local, and self-explanatory.
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: review-reliability
3
+ description: R3 Reliability reviewer — behavior-first tests, coverage value, edge cases, determinism, contracts, and regressions.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R3 Reliability**, a read-only reviewer. Find test and behavior risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `01-testing-setup.md`, `02-tdd-implementation.md`, `03-integration-testing.md`, `04-e2e-testing.md`, `10-strategic-coverage.md`, `11-playwright-visibility.md`, `12-quality-gates-husky.md`, `23-apis-components.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Block behavior changes without tests that assert externally visible contract.
14
+ - Flag tests that are implementation-centric instead of user/behavior-centric.
15
+ - Flag missing edge cases: boundaries, invalid inputs, empty states, retries, failure paths.
16
+ - Block when CI can pass with `test.only`; require `forbidOnly` or equivalent in CI configs.
17
+ - Flag misallocated test coverage: too much E2E where cheaper deterministic unit/integration tests should cover behavior.
18
+ - Require evidence of determinism: same input -> same output; external dependencies mocked or controlled.
19
+ - Flag weak selectors in UI tests; prefer semantic/user-visible queries.
20
+ - Do not flag intentional reliance on built-in async waiting/trace visibility over custom polling/logging.
21
+ - Require evidence that new APIs/components have example usage or documented contract.
22
+
23
+ ## Output contract
24
+
25
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: review-resilience
3
+ description: R4 Resilience reviewer — fallbacks, retry/backoff, graceful degradation, observability, load, rollback, and SLO risks.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R4 Resilience**, a read-only reviewer. Find operational failure risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `09-essential-metrics.md`, `13-observability-strategy.md`, `14-sentry-implementation.md`, `15-sentry-errors.md`, `16-sentry-performance.md`, `17-sentry-alertas.md`, `29-performance-percibida.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag failures with no fallback, retry, or graceful-degradation path.
14
+ - Block when production error-rate or build/test thresholds are ignored. Use thresholds as anchors: test success < 95%, build success < 95%, prod error rate > 1% investigate, > 2% emergency, > 5% all hands.
15
+ - Flag releases that can regress without alerting/observability hooks.
16
+ - Require evidence for rollback/fix-forward readiness: a concrete recovery path must exist.
17
+ - Flag performance regressions that exceed user-visible budgets or lack measurement.
18
+ - Block when there is no production visibility for error/performance issues expected in the wild.
19
+ - Do not flag explicitly low-impact expected issues already isolated by alert grouping or silence rules.
20
+ - Require evidence of SLO/latency/load impact, not generic "might be slow" claims.
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: review-risk
3
+ description: R1 Risk reviewer — security, privilege boundaries, data exposure, dependency risks, and merge-blocking vulnerabilities.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R1 Risk**, a read-only reviewer. Find security risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `18-env-secrets.md`, `19-web-security.md`, `20-auth-tokens.md`, `21-owasp-top10.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag when secrets, tokens, API keys, JWT secrets, or DB URLs are hardcoded in code or committed examples.
14
+ - Block when authz is enforced only in the frontend; require backend verification on every request.
15
+ - Flag when user input reaches HTML/DOM sinks without escaping/sanitization.
16
+ - Block when SQL/NoSQL/command strings are built by concatenation instead of parameterization.
17
+ - Flag when cookies storing auth state miss `httpOnly`, `secure`, or `sameSite` protections.
18
+ - Require evidence that security-sensitive changes are covered by backend checks, not UI disabled states.
19
+ - Do not flag when React default escaping is used and no raw HTML sink exists.
20
+ - Require evidence for dependency/security findings: cite scan failure or vulnerable package, not just "looks risky".
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: 4r-review
3
+ description: Pre-PR 4R review fan-out — runs all four review lenses (risk, readability, reliability, resilience) in sequence and writes individual reports.
4
+ ---
5
+
6
+ ## review-risk
7
+
8
+ output: review-risk-report.md
9
+ outputMode: file-only
10
+ progress: true
11
+
12
+ Run R1 Risk review on the current diff. Report security, privilege boundary, data exposure, dependency, and merge-blocking vulnerability findings. If clean, say exactly: `No findings.`
13
+
14
+ ## review-readability
15
+
16
+ reads: review-risk-report.md
17
+ output: review-readability-report.md
18
+ outputMode: file-only
19
+ progress: true
20
+
21
+ Run R2 Readability review on the current diff. Report naming, complexity, intention, maintainability, review size, and context clarity findings. If clean, say exactly: `No findings.`
22
+
23
+ ## review-reliability
24
+
25
+ reads: review-risk-report.md+review-readability-report.md
26
+ output: review-reliability-report.md
27
+ outputMode: file-only
28
+ progress: true
29
+
30
+ Run R3 Reliability review on the current diff. Report behavior-first test coverage, edge case, determinism, contract, and regression findings. If clean, say exactly: `No findings.`
31
+
32
+ ## review-resilience
33
+
34
+ reads: review-risk-report.md+review-readability-report.md+review-reliability-report.md
35
+ output: review-resilience-report.md
36
+ outputMode: file-only
37
+ progress: true
38
+
39
+ Run R4 Resilience review on the current diff. Report fallback, retry/backoff, graceful degradation, observability, load, rollback, and SLO risk findings. If clean, say exactly: `No findings.`
@@ -27,7 +27,7 @@ Keep synthesis short by default: decision, outcome, next action. Expand only whe
27
27
 
28
28
  ## Language Boundary
29
29
 
30
- User-facing conversation should stay in the user's language and follow the currently selected persona mode. In `gentleman` mode, Spanish uses natural Rioplatense voseo. In `neutral` mode, Spanish stays neutral/professional without regional expression.
30
+ User-facing conversation should stay in the user's language and follow the currently active persona mode. The active mode is stated in the `Current persona mode:` line in the identity/harness section of this system prompt — always honor it for language style.
31
31
 
32
32
  Subagent-facing prompts should be written in English by default, even when the user speaks Spanish. Translate the user's request into concise English before delegation. This keeps token usage lower and gives built-in/project subagents a consistent operating language without changing the user-facing persona.
33
33
 
@@ -271,6 +271,15 @@ When Engram or another callable memory package is available, the parent owns mem
271
271
  - SDD artifact keys: in memory/hybrid mode, phase artifacts should use stable topic keys such as `sdd/<change>/proposal`, `sdd/<change>/spec`, `sdd/<change>/design`, `sdd/<change>/tasks`, `sdd/<change>/apply-progress`, and `sdd/<change>/verify-report`.
272
272
  - If memory tools are unavailable, do not pretend persistence exists; return artifacts inline and/or write OpenSpec files.
273
273
 
274
+ Memory lifecycle rule (when Engram exposes lifecycle metadata/tooling):
275
+
276
+ - At session start or before architecture-sensitive work, call `mem_review` with action `list` for the current project when the tool is available.
277
+ - If `mem_review` is unavailable, do not fail the task. Continue with normal `mem_context`/`mem_search`, and still apply lifecycle metadata from any returned observations when present.
278
+ - `active` memories may be used normally.
279
+ - `needs_review` memories are stale context, not trusted facts.
280
+ - When a retrieved memory is marked `needs_review`, surface that stale context to the user and verify it against current evidence before relying on it.
281
+ - Do NOT call `mem_review` with action `mark_reviewed` automatically. Only call `mark_reviewed` after explicit user confirmation or through a dedicated memory maintenance command.
282
+
274
283
  ## Execution Mode
275
284
 
276
285
  Use the session's SDD preflight choice:
@@ -375,3 +384,19 @@ Automatic mode does not override reviewer burnout protection.
375
384
  - Ask before destructive git operations, publishing, or irreversible file changes.
376
385
  - Keep writes single-threaded unless isolated worktrees are explicitly approved.
377
386
  - Preserve human control: user decisions beat agent momentum.
387
+
388
+ ## 4R Review Triggers
389
+
390
+ The extension (`extensions/gentle-ai.ts`) gates `bash` tool calls that look like git/gh workflow events. Gate semantics:
391
+
392
+ - **pre-commit** (`git commit`): advisory only. The extension notifies the user to consider running `review-readability` but does NOT block. No orchestrator action needed.
393
+ - **pre-push** (`git push`): advisory only. Same as pre-commit — notify, do not block.
394
+ - **pre-pr** (`gh pr create`): **strong gate**. The extension blocks when any of these hold:
395
+ - Changed paths match hot globs: `**/auth/**`, `**/update/**`, `**/security/**`, `**/payments/**`
396
+ - Diff exceeds 400 changed lines (added + deleted)
397
+ - When blocked, the reason names all four agents to run first.
398
+ - **post-sdd-phase** (design, apply): **strong gate** for `judgment-day`. Handled separately by SDD phase orchestration, not this diff-based hook.
399
+
400
+ When the extension blocks a `gh pr create` command, the orchestrator must launch the `4r-review` chain (or run the four agents individually) and wait for their reports before the user retries the PR command.
401
+
402
+ Prohibition: do NOT configure the full 4R fan-out on `pre-commit` or `pre-push` with `always: true`. Everyday events must use a single advisory lens to keep development-loop cost low (spec G token-budget rule). The `validateTriggerRuleSet` function in `lib/review-triggers.ts` enforces this at config load time.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import {
2
3
  existsSync,
3
4
  mkdirSync,
@@ -38,6 +39,12 @@ import {
38
39
  sddStatusSeverity,
39
40
  type SddPhase,
40
41
  } from "../lib/sdd-status.ts";
42
+ import {
43
+ evaluateEvent,
44
+ matchPathGlobs,
45
+ type ChangedDiff,
46
+ type TriggerEvent,
47
+ } from "../lib/review-triggers.ts";
41
48
 
42
49
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
43
50
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -149,6 +156,7 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
149
156
  - Be direct, technical, concise, warm, and professional.
150
157
  - Always respond in the same language the user writes in.
151
158
  - Do not use slang or regional expressions.
159
+ - When the user writes Spanish, use neutral/professional Spanish. Do NOT use voseo (vos tenés, vos querés, hacé, andá, etc.) or any regional conjugations.
152
160
  - Act as a senior architect and teacher: concepts before code, no shortcuts.
153
161
  - Treat AI as a tool directed by the human; never present yourself as a default chatbot.
154
162
  - Push back when the user asks for code without enough context or understanding.
@@ -157,7 +165,14 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
157
165
  function buildGentlePrompt(persona: PersonaMode): string {
158
166
  const personaPrompt =
159
167
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
168
+ const languageBoundary =
169
+ persona === "neutral"
170
+ ? "Language: neutral/professional Spanish when the user writes Spanish. Do NOT use voseo or Rioplatense regional expressions."
171
+ : "Language: natural Rioplatense Spanish with voseo when the user writes Spanish.";
160
172
  return `## el Gentleman Identity and Harness
173
+
174
+ Current persona mode: ${persona}
175
+
161
176
  You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
162
177
 
163
178
  Identity contract:
@@ -169,6 +184,8 @@ Identity contract:
169
184
 
170
185
  ${personaPrompt}
171
186
 
187
+ ${languageBoundary}
188
+
172
189
  Harness principles:
173
190
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
174
191
  - Prefer SDD/OpenSpec artifacts over floating chat context for non-trivial work.
@@ -182,11 +199,19 @@ Harness principles:
182
199
  ${getOrchestratorPrompt()}`;
183
200
  }
184
201
 
202
+ // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
203
+ // between `git` and the subcommand. Short flags may be followed by a separate value token.
204
+ const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
205
+ const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b`);
206
+
185
207
  const DENIED_BASH_PATTERNS: RegExp[] = [
186
- /\brm\s+-rf\s+(?:\/|~|\$HOME|\.\.?)(?:\s|$)/,
208
+ // Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
209
+ /\brm\s+-rf\s+(?:\/(?:\s|$)|~(?:\/|\s|$)|[$]HOME(?:\/|\s|$)|\.\.?(?:\s|$))/,
187
210
  /\bgit\s+reset\s+--hard\b/,
188
211
  /\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/,
189
- /\bgit\s+push\b(?=[^\n]*\s--force(?:-with-lease)?\b)/,
212
+ // Force-push deny: tolerates git global flags (e.g. -C /repo) before the subcommand
213
+ new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s--force(?:-with-lease)?\b)`),
214
+ new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s-[^\s-]*f)`),
190
215
  /\bchmod\s+-R\s+777\b/,
191
216
  /\bchown\s+-R\b/,
192
217
  ];
@@ -194,11 +219,189 @@ const DENIED_BASH_PATTERNS: RegExp[] = [
194
219
  const CONFIRM_BASH_PATTERNS: RegExp[] = [
195
220
  /\bgit\s+push\b/,
196
221
  /\bgit\s+rebase\b/,
197
- /\bgit\s+branch\s+-D\b/,
222
+ /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
198
223
  /\bnpm\s+publish\b/,
199
224
  /\bpi\s+remove\b/,
200
225
  ];
201
226
 
227
+ // ---------------------------------------------------------------------------
228
+ // Autonomous guard — runtime guardrails config
229
+ // ---------------------------------------------------------------------------
230
+
231
+ const GUARD_ACTION = {
232
+ ALLOW: "allow",
233
+ CONFIRM: "confirm",
234
+ BLOCK: "block",
235
+ } as const;
236
+
237
+ type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
238
+ type GuardClassification = GuardAction | "not-guarded";
239
+
240
+ const GUARDED_COMMAND_KEY = {
241
+ GIT_PUSH: "gitPush",
242
+ GIT_REBASE: "gitRebase",
243
+ GIT_BRANCH_DELETE_FORCE: "gitBranchDeleteForce",
244
+ NPM_PUBLISH: "npmPublish",
245
+ PI_REMOVE: "piRemove",
246
+ } as const;
247
+
248
+ type GuardedCommandKey = (typeof GUARDED_COMMAND_KEY)[keyof typeof GUARDED_COMMAND_KEY];
249
+
250
+ type GuardedCommandsConfig = Partial<Record<GuardedCommandKey, GuardAction>>;
251
+
252
+ interface RuntimeGuardrailsConfig {
253
+ autonomousMode: boolean;
254
+ guardedCommands: GuardedCommandsConfig;
255
+ }
256
+
257
+ interface LoadGuardrailsOptions {
258
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
259
+ gentlePiConfigHome?: string;
260
+ }
261
+
262
+ const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
263
+ gitPush: GIT_PUSH_RE,
264
+ gitRebase: /\bgit\s+rebase\b/,
265
+ gitBranchDeleteForce: /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
266
+ npmPublish: /\bnpm\s+publish\b/,
267
+ piRemove: /\bpi\s+remove\b/,
268
+ };
269
+
270
+ const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
271
+ gitPush: "allow",
272
+ gitRebase: "confirm",
273
+ gitBranchDeleteForce: "confirm",
274
+ npmPublish: "block",
275
+ piRemove: "confirm",
276
+ };
277
+
278
+ const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
279
+ autonomousMode: false,
280
+ guardedCommands: {},
281
+ };
282
+
283
+ /**
284
+ * Classify a shell command under the runtime guard policy.
285
+ *
286
+ * Ordering (non-negotiable):
287
+ * 1. Hard-deny patterns → "block" (always, cannot be overridden by config)
288
+ * 2. If autonomousMode is false → mirror the legacy CONFIRM_BASH_PATTERNS result
289
+ * 3. If autonomousMode is true → use configured GuardAction for the matched key
290
+ * (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
291
+ * 4. No match → "not-guarded"
292
+ */
293
+ function classifyGuardedCommand(
294
+ command: string,
295
+ config: RuntimeGuardrailsConfig,
296
+ ): GuardClassification {
297
+ // Step 1: hard-deny always wins, regardless of any config
298
+ for (const pattern of DENIED_BASH_PATTERNS) {
299
+ if (pattern.test(command)) return "block";
300
+ }
301
+
302
+ // Step 2 & 3: find which guarded key (if any) this command matches
303
+ for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [GuardedCommandKey, RegExp][]) {
304
+ if (!pattern.test(command)) continue;
305
+
306
+ // Matched a guarded key
307
+ if (!config.autonomousMode) {
308
+ // Legacy behavior: any match → confirm
309
+ return "confirm";
310
+ }
311
+
312
+ // Autonomous mode: use configured action, fall back to sensible defaults
313
+ const configuredAction = config.guardedCommands[key];
314
+ return configuredAction ?? AUTONOMOUS_DEFAULT_ACTIONS[key];
315
+ }
316
+
317
+ return "not-guarded";
318
+ }
319
+
320
+ function parseGuardrailsConfigFile(
321
+ raw: string,
322
+ ): RuntimeGuardrailsConfig | undefined {
323
+ let parsed: unknown;
324
+ try {
325
+ parsed = JSON.parse(raw);
326
+ } catch {
327
+ return undefined;
328
+ }
329
+ if (!isRecord(parsed)) return undefined;
330
+
331
+ const autonomousMode = parsed.autonomousMode === true;
332
+
333
+ const rawCommands = isRecord(parsed.guardedCommands) ? parsed.guardedCommands : {};
334
+ const guardedCommands: GuardedCommandsConfig = {};
335
+ const validActions = new Set<string>(["allow", "confirm", "block"]);
336
+ for (const [key, value] of Object.entries(rawCommands)) {
337
+ if (
338
+ typeof value === "string" &&
339
+ validActions.has(value) &&
340
+ Object.values(GUARDED_COMMAND_KEY).includes(key as GuardedCommandKey)
341
+ ) {
342
+ guardedCommands[key as GuardedCommandKey] = value as GuardAction;
343
+ }
344
+ }
345
+
346
+ return { autonomousMode, guardedCommands };
347
+ }
348
+
349
+ /**
350
+ * Load the runtime guardrails config.
351
+ *
352
+ * Resolution order (project overrides global):
353
+ * 1. Check GENTLE_PI_AUTONOMOUS_MODE env var — if "1", forces autonomousMode=true
354
+ * and uses default guarded command actions.
355
+ * 2. Read global config from ${gentlePiConfigHome}/runtime-guardrails.json
356
+ * 3. Read project config from ${cwd}/.pi/gentle-ai/runtime-guardrails.json
357
+ * (project values are merged on top of global)
358
+ * 4. Any parse/read error anywhere → fail safe (return SAFE_GUARDRAILS_CONFIG)
359
+ */
360
+ function loadRuntimeGuardrailsConfig(
361
+ cwd: string,
362
+ options: LoadGuardrailsOptions = {},
363
+ ): RuntimeGuardrailsConfig {
364
+ try {
365
+ // Env var override: forces autonomous mode with default actions
366
+ if (process.env.GENTLE_PI_AUTONOMOUS_MODE === "1") {
367
+ return { autonomousMode: true, guardedCommands: {} };
368
+ }
369
+
370
+ const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome();
371
+ const globalConfigPath = join(configHome, "runtime-guardrails.json");
372
+ const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json");
373
+
374
+ let merged: RuntimeGuardrailsConfig = { autonomousMode: false, guardedCommands: {} };
375
+
376
+ if (existsSync(globalConfigPath)) {
377
+ const globalParsed = parseGuardrailsConfigFile(
378
+ readFileSync(globalConfigPath, "utf8"),
379
+ );
380
+ if (!globalParsed) return SAFE_GUARDRAILS_CONFIG;
381
+ merged = globalParsed;
382
+ }
383
+
384
+ if (existsSync(projectConfigPath)) {
385
+ const projectParsed = parseGuardrailsConfigFile(
386
+ readFileSync(projectConfigPath, "utf8"),
387
+ );
388
+ if (!projectParsed) return SAFE_GUARDRAILS_CONFIG;
389
+ // Project values fully override global values
390
+ merged = {
391
+ autonomousMode: projectParsed.autonomousMode,
392
+ guardedCommands: {
393
+ ...merged.guardedCommands,
394
+ ...projectParsed.guardedCommands,
395
+ },
396
+ };
397
+ }
398
+
399
+ return merged;
400
+ } catch {
401
+ return SAFE_GUARDRAILS_CONFIG;
402
+ }
403
+ }
404
+
202
405
  const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]);
203
406
  const PATH_INPUT_KEYS = new Set([
204
407
  "path",
@@ -329,25 +532,6 @@ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
329
532
  return undefined;
330
533
  }
331
534
 
332
- function evaluateDeniedCommand(
333
- command: string,
334
- ): ToolCallEventResult | undefined {
335
- for (const pattern of DENIED_BASH_PATTERNS) {
336
- if (pattern.test(command)) {
337
- return {
338
- block: true,
339
- reason:
340
- "Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.",
341
- };
342
- }
343
- }
344
- return undefined;
345
- }
346
-
347
- function commandRequiresConfirmation(command: string): boolean {
348
- return CONFIRM_BASH_PATTERNS.some((pattern) => pattern.test(command));
349
- }
350
-
351
535
  function normalizePolicyPath(value: string): string {
352
536
  return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase();
353
537
  }
@@ -408,9 +592,23 @@ async function confirmCommand(
408
592
  command: string,
409
593
  ctx: ExtensionContext,
410
594
  ): Promise<ToolCallEventResult | undefined> {
411
- const denied = evaluateDeniedCommand(command);
412
- if (denied) return denied;
413
- if (!commandRequiresConfirmation(command)) return undefined;
595
+ const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
596
+ const classification = classifyGuardedCommand(command, guardrailsConfig);
597
+
598
+ if (classification === "block") {
599
+ return {
600
+ block: true,
601
+ reason:
602
+ "Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.",
603
+ };
604
+ }
605
+
606
+ if (classification === "not-guarded") return undefined;
607
+
608
+ // classification is "allow" or "confirm" from this point on
609
+ if (classification === "allow") return undefined;
610
+
611
+ // classification === "confirm"
414
612
  if (!ctx.hasUI) {
415
613
  return {
416
614
  block: true,
@@ -1569,10 +1767,148 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
1569
1767
  );
1570
1768
  }
1571
1769
 
1770
+ // ---------------------------------------------------------------------------
1771
+ // Review gate helpers — pure, exported via __testing for unit tests
1772
+ // ---------------------------------------------------------------------------
1773
+
1774
+ /**
1775
+ * Classifies a bash command string as a TriggerEvent for the review gate,
1776
+ * or returns null if the command is not a recognized git/gh workflow trigger.
1777
+ *
1778
+ * Regexes are tolerant of flags between tokens.
1779
+ */
1780
+ export function classifyReviewEvent(command: string): TriggerEvent | null {
1781
+ const trimmed = command.trim();
1782
+ // gh pr create → pre-pr (check before generic push to avoid overlap)
1783
+ if (/^gh\s+pr\s+create\b/.test(trimmed)) return "pre-pr";
1784
+ // git commit → pre-commit
1785
+ if (/^git(?:\s+(?:-C\s+\S+|--work-tree=\S+|--git-dir=\S+))?\s+commit\b/.test(trimmed))
1786
+ return "pre-commit";
1787
+ // git push → pre-push
1788
+ if (/^git(?:\s+(?:-C\s+\S+|--work-tree=\S+|--git-dir=\S+))?\s+push\b/.test(trimmed))
1789
+ return "pre-push";
1790
+ return null;
1791
+ }
1792
+
1793
+ /**
1794
+ * Parses the output of `git diff --numstat` into a ChangedDiff.
1795
+ * Binary files show `- - path`; their contribution to changedLines is 0.
1796
+ */
1797
+ export function parseNumstat(output: string): ChangedDiff {
1798
+ const changedPaths: string[] = [];
1799
+ let changedLines = 0;
1800
+ for (const line of output.split("\n")) {
1801
+ const trimmed = line.trim();
1802
+ if (!trimmed) continue;
1803
+ // Format: "<added>\t<deleted>\t<path>"
1804
+ const parts = trimmed.split("\t");
1805
+ if (parts.length < 3) continue;
1806
+ const added = parts[0];
1807
+ const deleted = parts[1];
1808
+ const filePath = parts.slice(2).join("\t");
1809
+ if (!filePath) continue;
1810
+ changedPaths.push(filePath);
1811
+ // Binary rows have "-" in both columns; treat as 0.
1812
+ const addedNum = added === "-" ? 0 : parseInt(added, 10);
1813
+ const deletedNum = deleted === "-" ? 0 : parseInt(deleted, 10);
1814
+ if (!isNaN(addedNum)) changedLines += addedNum;
1815
+ if (!isNaN(deletedNum)) changedLines += deletedNum;
1816
+ }
1817
+ return { changedPaths, changedLines };
1818
+ }
1819
+
1820
+ /**
1821
+ * Computes a ChangedDiff for the given event by running git numstat.
1822
+ * Returns null on any error (fail open — never break the user's git command).
1823
+ */
1824
+ function computeDiffForEvent(event: TriggerEvent, cwd: string): ChangedDiff | null {
1825
+ const gitOpts = {
1826
+ cwd,
1827
+ encoding: "utf8" as const,
1828
+ stdio: ["pipe", "pipe", "pipe"] as const,
1829
+ // Bound synchronous git calls so a slow/large repo cannot freeze the extension process.
1830
+ // The existing outer try/catch returns null (fail-open) when this throws.
1831
+ timeout: 2000,
1832
+ };
1833
+ try {
1834
+ let raw: string;
1835
+ if (event === "pre-commit") {
1836
+ raw = execFileSync("git", ["diff", "--cached", "--numstat"], gitOpts);
1837
+ } else {
1838
+ // pre-push or pre-pr: diff vs merge-base
1839
+ let base = "";
1840
+ for (const ref of ["origin/HEAD", "origin/main", "main"]) {
1841
+ try {
1842
+ base = execFileSync("git", ["merge-base", "HEAD", ref], gitOpts).trim();
1843
+ if (base) break;
1844
+ } catch {
1845
+ // try next ref
1846
+ }
1847
+ }
1848
+ if (!base) {
1849
+ // Final fallback: cached diff
1850
+ try {
1851
+ raw = execFileSync("git", ["diff", "--cached", "--numstat"], gitOpts);
1852
+ return parseNumstat(raw);
1853
+ } catch {
1854
+ return null;
1855
+ }
1856
+ }
1857
+ raw = execFileSync("git", ["diff", "--numstat", `${base}...HEAD`], gitOpts);
1858
+ }
1859
+ return parseNumstat(raw);
1860
+ } catch {
1861
+ return null;
1862
+ }
1863
+ }
1864
+
1865
+ /**
1866
+ * Runs the review gate for a bash command, composing with the existing
1867
+ * confirmCommand flow. Returns a block result for strong mode, notifies for
1868
+ * advisory mode, or returns undefined to fall through.
1869
+ */
1870
+ async function applyReviewGate(
1871
+ command: string,
1872
+ ctx: ExtensionContext,
1873
+ ): Promise<ToolCallEventResult | undefined> {
1874
+ const event = classifyReviewEvent(command);
1875
+ if (!event) return undefined;
1876
+
1877
+ const diff = computeDiffForEvent(event, ctx.cwd);
1878
+ if (!diff) return undefined;
1879
+
1880
+ const result = evaluateEvent(event, diff);
1881
+ if (!result) return undefined;
1882
+
1883
+ if (result.mode === "advisory") {
1884
+ if (ctx.hasUI) {
1885
+ const commitOrPush = event === "pre-push" ? "this push" : "this commit";
1886
+ ctx.ui.notify(
1887
+ `Review suggestion: consider running agent "${result.run.join(", ")}" before ${commitOrPush}. ${result.reason}`,
1888
+ "info",
1889
+ );
1890
+ }
1891
+ return undefined;
1892
+ }
1893
+
1894
+ // strong mode — block
1895
+ return {
1896
+ block: true,
1897
+ reason:
1898
+ `Gentle AI 4R review gate: run ${result.run.join(", ")} before this command. ` +
1899
+ result.reason,
1900
+ };
1901
+ }
1902
+
1572
1903
  /** @internal */
1573
1904
  export const __testing = {
1574
1905
  listAgentsFromDir,
1575
1906
  listAgentsFromDirAsync,
1907
+ classifyGuardedCommand,
1908
+ loadRuntimeGuardrailsConfig,
1909
+ buildGentlePrompt,
1910
+ classifyReviewEvent,
1911
+ parseNumstat,
1576
1912
  };
1577
1913
 
1578
1914
  export default function gentleAi(pi: ExtensionAPI): void {
@@ -1656,6 +1992,8 @@ export default function gentleAi(pi: ExtensionAPI): void {
1656
1992
  if (event.toolName !== "bash") return undefined;
1657
1993
  if (!isRecord(event.input) || typeof event.input.command !== "string")
1658
1994
  return undefined;
1995
+ const reviewGateResult = await applyReviewGate(event.input.command, ctx);
1996
+ if (reviewGateResult) return reviewGateResult;
1659
1997
  return confirmCommand(event.input.command, ctx);
1660
1998
  });
1661
1999