Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Supply-chain quarantine: refuse to install any npm package published < 7 days ago.
# Organization-wide policy; critical after 2026-05-12 Mini Shai-Hulud wave
# (@mistralai/mistralai 2.2.2-2.2.4, 169 packages total).
min-release-age=7
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 1.3.0 (2026-05-11)

### Features

- **Persistent + runtime-toggleable enabled state.** New `enabled: boolean` setting in `.hardno/settings.json` (default `true`). Persisted across sessions — previously the Alt+R / `/review` toggle was in-memory only and reset on restart. Now the state sticks.

- **External runtime toggle.** The extension re-reads just the `enabled` field from disk at each `agent_end`, so external tools (e.g. `roundhouse`'s `/toggle-review`) can flip it by writing to `~/.pi/.hardno/settings.json` without restarting the session. New exports: `isEnabledFromDisk()`, `writeEnabledToDisk()`.

- **Atomic writes.** Toggle persistence uses tmp+rename so a crash mid-write never leaves a partial settings file.

### Notes

- Default behavior unchanged: no `enabled` key → treated as `true`.
- Local `.hardno/settings.json` (cwd) takes precedence over global (`~/.pi/.hardno/`). When local exists but has no `enabled` key, it does NOT fall through to global — the more-specific file "wins" on silence too.

## 1.2.0 (2026-05-10)

### Features
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Use `/scaffold-review-files` to generate config templates.

```json
{
"enabled": true,
"maxReviewLoops": 100,
"model": "amazon-bedrock/us.meta.llama4-maverick-17b-instruct-v1:0",
"thinkingLevel": "off",
Expand All @@ -84,21 +85,24 @@ Use `/scaffold-review-files` to generate config templates.
}
```

| Setting | Type | Default | Description |
| ------------------ | ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `maxReviewLoops` | integer > 0 | `100` | Max review→fix→review cycles before giving up |
| `model` | string | `"amazon-bedrock/us.meta.llama4-maverick-17b-instruct-v1:0"` | Reviewer model (`"provider/model-id"`) |
| `thinkingLevel` | string | `"off"` | `off\|minimal\|low\|medium\|high\|xhigh` |
| `architectEnabled` | boolean | `true` | Enable architect review (triggers when >1 file reviewed from git) |
| `reviewTimeoutMs` | integer > 0 | `120000` | Max wall-clock per review in ms |
| `toggleShortcut` | string | `"alt+r"` | Key id for toggling review on/off |
| `judgeEnabled` | boolean | `false` | Opt-in LLM gate that suppresses redundant reviews on read-only turns (see [Judge](#judge)) |
| `judgeModel` | string | `"amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"` | Model used by the judge (`"provider/model-id"`) |
| `judgeTimeoutMs` | integer > 0 | `10000` | Max wall-clock per judge classification call in ms |
| `cancelShortcut` | string | `""` (none) | Key id for cancelling review (opt-in, see below) |
| Setting | Type | Default | Description |
| ------------------ | ----------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | Master on/off for auto-review. Persisted; re-read each turn so external tools (e.g. roundhouse `/toggle-review`) take effect without session restart. |
| `maxReviewLoops` | integer > 0 | `100` | Max review→fix→review cycles before giving up |
| `model` | string | `"amazon-bedrock/us.meta.llama4-maverick-17b-instruct-v1:0"` | Reviewer model (`"provider/model-id"`) |
| `thinkingLevel` | string | `"off"` | `off\|minimal\|low\|medium\|high\|xhigh` |
| `architectEnabled` | boolean | `true` | Enable architect review (triggers when >1 file reviewed from git) |
| `reviewTimeoutMs` | integer > 0 | `120000` | Max wall-clock per review in ms |
| `toggleShortcut` | string | `"alt+r"` | Key id for toggling review on/off |
| `judgeEnabled` | boolean | `false` | Opt-in LLM gate that suppresses redundant reviews on read-only turns (see [Judge](#judge)) |
| `judgeModel` | string | `"amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"` | Model used by the judge (`"provider/model-id"`) |
| `judgeTimeoutMs` | integer > 0 | `10000` | Max wall-clock per judge classification call in ms |
| `cancelShortcut` | string | `""` (none) | Key id for cancelling review (opt-in, see below) |

> **Note:** `roundupEnabled` is accepted as a legacy alias for `architectEnabled`.

> **`enabled` toggle persistence & routing:** The in-TUI toggle (Alt+R / `/review`) and external tools write to whichever file `loadSettings` would read — local `cwd/.hardno/settings.json` if present, else global `~/.pi/.hardno/settings.json`. This matches the read precedence so a toggle never silently no-ops. The extension re-reads just the `enabled` field at each `agent_end`, so flipping it externally (e.g. via roundhouse `/toggle-review`) takes effect on the NEXT agent turn — no session restart needed.

### `.hardno/review-rules.md`

Custom review rules appended to the reviewer prompt. Only include review criteria — the surrounding prompt (tools, budget, workflow, response format) is handled automatically.
Expand Down
33 changes: 20 additions & 13 deletions dismiss.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { describe, it, expect } from "vitest";
import { findingKey, numberFindings, parseDismissals, filterSuppressed, DismissTracker } from "./dismiss";
import {
findingKey,
numberFindings,
parseDismissals,
filterSuppressed,
DismissTracker,
} from "./dismiss";

describe("findingKey", () => {
it("extracts severity + location from finding line", () => {
const line = '- **Medium:** src/gateway/model.ts:97 — chatId extraction uses wrong index';
const line = "- **Medium:** src/gateway/model.ts:97 — chatId extraction uses wrong index";
const key = findingKey(line);
expect(key).toContain("medium:src/gateway/model.ts:97");
});

it("handles numbered finding format (F# prefix)", () => {
const line = '- **F1 Medium:** src/foo.ts:10 — something bad';
const line = "- **F1 Medium:** src/foo.ts:10 — something bad";
const key = findingKey(line);
expect(key).toBe("medium:src/foo.ts:10 — something bad");
});
Expand All @@ -22,7 +28,7 @@ describe("findingKey", () => {

describe("numberFindings", () => {
it("numbers finding bullets sequentially", () => {
const text = '- **High:** foo.ts:1 — bug\n- **Low:** bar.ts:2 — nit\nSome other text';
const text = "- **High:** foo.ts:1 — bug\n- **Low:** bar.ts:2 — nit\nSome other text";
const { numbered, findings } = numberFindings(text);
expect(numbered).toContain("**F1 High:**");
expect(numbered).toContain("**F2 Low:**");
Expand All @@ -31,7 +37,7 @@ describe("numberFindings", () => {
});

it("preserves non-finding lines unchanged", () => {
const text = 'Header\n\n- **Medium:** x.ts:5 — issue\n\nFooter';
const text = "Header\n\n- **Medium:** x.ts:5 — issue\n\nFooter";
const { numbered } = numberFindings(text);
expect(numbered).toContain("Header");
expect(numbered).toContain("Footer");
Expand All @@ -41,7 +47,8 @@ describe("numberFindings", () => {

describe("parseDismissals", () => {
it("parses DISMISS F# with colon separator", () => {
const text = "The chatId extraction is intentional.\nDISMISS F1: intentional design for telegram thread format";
const text =
"The chatId extraction is intentional.\nDISMISS F1: intentional design for telegram thread format";
const dismissals = parseDismissals(text);
expect(dismissals.size).toBe(1);
expect(dismissals.get(1)).toBe("intentional design for telegram thread format");
Expand Down Expand Up @@ -69,29 +76,29 @@ describe("parseDismissals", () => {

describe("filterSuppressed", () => {
it("removes suppressed findings", () => {
const text = '- **High:** foo.ts:1 — bug one\n- **Low:** bar.ts:2 — nit two';
const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
const text = "- **High:** foo.ts:1 — bug one\n- **Low:** bar.ts:2 — nit two";
const suppressed = new Set([findingKey("- **High:** foo.ts:1 — bug one")]);
const result = filterSuppressed(text, suppressed);
expect(result).not.toContain("bug one");
expect(result).toContain("nit two");
});

it("returns null when all findings suppressed", () => {
const text = '- **High:** foo.ts:1 — bug one';
const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
const text = "- **High:** foo.ts:1 — bug one";
const suppressed = new Set([findingKey("- **High:** foo.ts:1 — bug one")]);
expect(filterSuppressed(text, suppressed)).toBeNull();
});

it("returns original when no suppressions", () => {
const text = '- **Low:** x.ts:5 — something';
const text = "- **Low:** x.ts:5 — something";
expect(filterSuppressed(text, new Set())).toBe(text);
});
});

describe("DismissTracker", () => {
it("tracks dismissals and suppresses after threshold", () => {
const tracker = new DismissTracker();
const findings = ['- **Medium:** src/foo.ts:10 — bad pattern', '- **Low:** src/bar.ts:5 — nit'];
const findings = ["- **Medium:** src/foo.ts:10 — bad pattern", "- **Low:** src/bar.ts:5 — nit"];
tracker.setLastFindings(findings);

// First dismiss
Expand All @@ -106,7 +113,7 @@ describe("DismissTracker", () => {

it("reset clears all state", () => {
const tracker = new DismissTracker();
tracker.setLastFindings(['- **High:** x.ts:1 — bug']);
tracker.setLastFindings(["- **High:** x.ts:1 — bug"]);
tracker.processDismissals("DISMISS F1: nope");
tracker.processDismissals("DISMISS F1: nope again");
expect(tracker.getSuppressed().size).toBe(1);
Expand Down
32 changes: 18 additions & 14 deletions dismiss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,18 @@ export function numberFindings(text: string): { numbered: string; findings: stri
const findings: string[] = [];
let counter = 0;

const numbered = lines.map(line => {
// Match finding bullets: - **Severity:** ...
const match = line.match(/^(\s*-\s*)\*\*(\w+):\*\*(.*)$/);
if (match) {
counter++;
findings.push(line);
return `${match[1]}**F${counter} ${match[2]}:**${match[3]}`;
}
return line;
}).join("\n");
const numbered = lines
.map((line) => {
// Match finding bullets: - **Severity:** ...
const match = line.match(/^(\s*-\s*)\*\*(\w+):\*\*(.*)$/);
if (match) {
counter++;
findings.push(line);
return `${match[1]}**F${counter} ${match[2]}:**${match[3]}`;
}
return line;
})
.join("\n");

return { numbered, findings };
}
Expand All @@ -53,7 +55,7 @@ export function numberFindings(text: string): { numbered: string; findings: stri
export function parseDismissals(text: string): Map<number, string> {
const dismissals = new Map<number, string>();
// Match: DISMISS F1: reason or DISMISS F1 - reason or DISMISS F1 reason
const pattern = /DISMISS\s+F(\d+)\s*[:–\-]\s*(.+)/gi;
const pattern = /DISMISS\s+F(\d+)\s*[:–-]\s*(.+)/gi;
let match;
while ((match = pattern.exec(text)) !== null) {
dismissals.set(parseInt(match[1], 10), match[2].trim());
Expand All @@ -66,15 +68,15 @@ export function filterSuppressed(text: string, suppressed: Set<string>): string
if (suppressed.size === 0) return text;

const lines = text.split("\n");
const filtered = lines.filter(line => {
const filtered = lines.filter((line) => {
const match = line.match(/^\s*-\s*\*\*\w+:\*\*/);
if (!match) return true; // keep non-finding lines
const key = findingKey(line);
return !suppressed.has(key);
});

// If all findings were suppressed, return null (should be LGTM)
const remaining = filtered.filter(l => l.match(/^\s*-\s*\*\*/));
const remaining = filtered.filter((l) => l.match(/^\s*-\s*\*\*/));
if (remaining.length === 0) return null;

return filtered.join("\n");
Expand Down Expand Up @@ -112,7 +114,9 @@ export class DismissTracker {
this.dismissed.set(key, { key, reason, count: 1 });
}
count++;
log(`dismiss: F${fNum} dismissed (${key}) — "${reason}" [count=${this.dismissed.get(key)!.count}]`);
log(
`dismiss: F${fNum} dismissed (${key}) — "${reason}" [count=${this.dismissed.get(key)!.count}]`,
);
}
return count;
}
Expand Down
35 changes: 33 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
loadReviewRules,
loadAutoReviewRules,
loadShortcutSettingsSync,
isEnabledFromDisk,
writeEnabledToDisk,
} from "./settings";
import { runReviewSession } from "./reviewer";
import { classifyBashCommand, defaultJudgeRunner } from "./judge";
Expand Down Expand Up @@ -272,6 +274,15 @@ export default function (pi: ExtensionAPI) {

try {
orchestrator.setEnabled(!orchestrator.isEnabled);
// Persist so the toggle survives restart + is visible to external tools.
// Write to the same file the read path would load (local wins if present)
// so an in-TUI toggle isn't masked by a local settings file on next read.
try {
writeEnabledToDisk(orchestrator.isEnabled, { cwd: ctx.cwd });
if (settings) settings.enabled = orchestrator.isEnabled;
} catch (err: any) {
log(`warning: could not persist toggle: ${err?.message ?? err}`);
}
if (orchestrator.isEnabled) {
if (ctx.hasUI) ctx.ui.notify(`Review: on`, "info");
// Only prompt to review if agent is idle and there are pending files.
Expand Down Expand Up @@ -706,13 +717,28 @@ export default function (pi: ExtensionAPI) {

// Process DISMISS markers from agent's response (before running review)
if (lastAssistant) {
const textParts = (lastAssistant.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text);
const textParts = (lastAssistant.content ?? [])
.filter((b: any) => b.type === "text")
.map((b: any) => b.text);
const agentText = textParts.join("\n");
if (agentText) {
orchestrator.processDismissals(agentText);
}
}

// Runtime re-read: pick up toggles made by external tools (e.g.
// roundhouse's /toggle-review) since the last turn. Cheap — synchronous
// stat+parse of a tiny JSON file once per agent_end.
const diskEnabled = isEnabledFromDisk(ctx.cwd);
if (diskEnabled !== null && diskEnabled !== orchestrator.isEnabled) {
log(`runtime toggle: disk says enabled=${diskEnabled}, updating orchestrator`);
orchestrator.setEnabled(diskEnabled);
// F4: guard like the toggleReview handler for consistency. `settings`
Comment on lines +733 to +736

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve manual toggle when disk persistence fails

When writeEnabledToDisk fails (for example due to read-only config files or permission errors), the toggle handler still updates orchestrator.isEnabled, but the next agent_end unconditionally re-syncs from disk and overwrites that in-memory state. In this failure mode, Alt+R//review appears to work briefly and then flips back on the next turn, which regresses the prior in-memory-only behavior exactly when persistence is unavailable.

Useful? React with 👍 / 👎.

// is set on session_start before agent_end can fire, but defensive
// code is cheap and matches the other call site.
if (settings) settings.enabled = diskEnabled;
}

if (!orchestrator.isEnabled) {
// Keep tracking state (modifiedFiles, agentToolCalls) so we can
// offer to review when the user toggles review back on.
Expand All @@ -724,7 +750,7 @@ export default function (pi: ExtensionAPI) {
await runAutoReview(ctx, "auto");
});

// ── Shortcuts ──────────────────────────────────────
// ── Shortcuts ──────────────────────────────────

// Cancel handler — shared by shortcut + command
function cancelReview(ctx: { ui: any; hasUI?: boolean }, source: string) {
Expand Down Expand Up @@ -874,6 +900,11 @@ export default function (pi: ExtensionAPI) {
architectRules = rRules;
settings = settingsResult.settings;

// Seed orchestrator enabled-state from persisted settings. This makes the
// toggle survive restarts (previously it was reset to `true` each session).
orchestrator.setEnabled(settings.enabled);
if (!settings.enabled) log(`review starts disabled (persisted enabled=false)`);

if (autoReviewRules) log("Loaded auto-review rules from .hardno/auto-review.md");
if (customRules) log("Loaded custom rules from .hardno/review-rules.md");
if (architectRules) log("Loaded architect rules from .hardno/architect.md");
Expand Down
6 changes: 5 additions & 1 deletion orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ export class ReviewOrchestrator {
// All findings suppressed — treat as LGTM
if (filtered === null) {
log("dismiss: all findings suppressed — treating as LGTM");
return { ...result, isLgtm: true, text: "No issues found (previously dismissed findings suppressed)." };
return {
...result,
isLgtm: true,
text: "No issues found (previously dismissed findings suppressed).",
};
}

// Number remaining findings and track them
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inceptionstack/pi-hard-no",
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"description": "Pi extension — automatic code review after every agent turn",
"license": "MIT",
Expand Down
Loading
Loading