rulereceipt 0.1.73 → 0.1.74

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.
@@ -46,6 +46,8 @@ export interface ApprovalOptions {
46
46
  allow?: string[];
47
47
  /** When set, a `push` action is only gated if it targets this branch (or its target is unknown). */
48
48
  scopedBranch?: string;
49
+ /** The current git branch (guard only), so a bare `git push` from a feature branch is not gated by a "push to main" rule. */
50
+ currentBranch?: string;
49
51
  }
50
52
  interface Occurrence {
51
53
  action: Action;
@@ -75,7 +75,7 @@ export function approvalScopedBranch(rule) {
75
75
  * push to a DIFFERENT branch returns false, so a feature-branch push is not
76
76
  * gated by a rule that names main.
77
77
  */
78
- function pushTargetsBranch(command, branch) {
78
+ function pushTargetsBranch(command, branch, currentBranch) {
79
79
  const m = command.match(/\bgit\s+(?:\S+\s+){0,4}?push\b(.*)/i);
80
80
  if (!m)
81
81
  return true;
@@ -88,7 +88,13 @@ function pushTargetsBranch(command, branch) {
88
88
  const b = tokens[tokens.length - 1];
89
89
  return b === branch || b.endsWith(`/${branch}`);
90
90
  }
91
- return true; // bare `git push` / `git push origin` — unknown target, gate to be safe
91
+ // Bare `git push` / `git push origin` — the target is the CURRENT branch. When
92
+ // the guard can tell us that branch, gate only if it is the scoped branch (a
93
+ // bare push from a feature branch is not a push to main). When it is unknown
94
+ // (the check path, or git unavailable), gate to be safe.
95
+ if (currentBranch)
96
+ return currentBranch === branch || currentBranch.endsWith(`/${branch}`);
97
+ return true;
92
98
  }
93
99
  /** The result for the call at `i`: matched by id when present, else the next result. */
94
100
  function resultOf(events, i) {
@@ -128,7 +134,7 @@ export function approvalOccurrences(events, actions, opts = {}) {
128
134
  continue;
129
135
  // A branch-scoped push rule ("push to main") does not gate a push to a
130
136
  // different branch — only main (or a bare push whose target is unknown).
131
- if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch))
137
+ if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch, opts.currentBranch))
132
138
  continue;
133
139
  const res = resultOf(events, i);
134
140
  if (res && res.kind === "tool_result" && res.isError)
package/dist/guard.js CHANGED
@@ -8,6 +8,23 @@ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js"
8
8
  import { commandRunsLiteral } from "./checks/proposedAction.js";
9
9
  import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
10
10
  import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
11
+ import { execFileSync } from "node:child_process";
12
+ /**
13
+ * The current git branch in `cwd`, or undefined if it can't be determined —
14
+ * so a bare `git push` from a feature branch is not gated by a "push to main"
15
+ * rule. Fails open (never throws, never blocks) if git is unavailable.
16
+ */
17
+ function gitCurrentBranch(cwd) {
18
+ try {
19
+ // `symbolic-ref --short HEAD` gives the branch name even on an unborn branch
20
+ // (no commits yet); `rev-parse --abbrev-ref` returns "HEAD" there.
21
+ const b = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
22
+ return b && b !== "HEAD" ? b : undefined;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
11
28
  import { readFileSync } from "node:fs";
12
29
  import { homedir } from "node:os";
13
30
  import { join } from "node:path";
@@ -204,7 +221,7 @@ function unapprovedGate(cwd, command, events) {
204
221
  const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
205
222
  for (const { rule, actions } of gates) {
206
223
  const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
207
- const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule) });
224
+ const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule), currentBranch: gitCurrentBranch(cwd) });
208
225
  const last = occ[occ.length - 1];
209
226
  // Compare against the SAME canonical short the occurrence uses (mention
210
227
  // segments dropped, `sh -c` unwrapped) — a raw-string compare missed a
@@ -1,6 +1,18 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { listAllSessions } from "./adapters/index.js";
3
3
  import { evaluateSession } from "./evaluate.js";
4
+ /**
5
+ * One-line clip that ends on a whole word with an ellipsis, never mid-sentence.
6
+ * Found by a real test 2026-09-29: a break quote was cut as "...so no prompt was".
7
+ */
8
+ function clip(s, n) {
9
+ const one = s.replace(/\s+/g, " ").trim();
10
+ if (one.length <= n)
11
+ return one;
12
+ const cut = one.slice(0, n);
13
+ const lastSpace = cut.lastIndexOf(" ");
14
+ return `${(lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.—-]+$/, "")}…`;
15
+ }
4
16
  function needsLlmResult(rule) {
5
17
  return { ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source, status: "UNCLEAR", needsHuman: true, evidence: "" };
6
18
  }
@@ -125,11 +137,11 @@ export function renderHistory(s, projectName, now = Date.now()) {
125
137
  out.push(`${who} broke your rules ${s.totalBrokenCount} time${s.totalBrokenCount === 1 ? "" : "s"}.`);
126
138
  out.push("");
127
139
  for (const b of s.breaks.slice(0, 10)) {
128
- const title = b.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 60);
140
+ const title = clip(b.ruleTitle, 60);
129
141
  const when = relDate(b.lastMs, now);
130
142
  out.push(` x ${title} ${b.count} time${b.count === 1 ? "" : "s"} last: ${when}`);
131
143
  if (b.quote)
132
- out.push(` ${b.quote.replace(/\s+/g, " ").trim().slice(0, 100)}`);
144
+ out.push(` ${clip(b.quote, 100)}`);
133
145
  }
134
146
  }
135
147
  out.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.73",
3
+ "version": "0.1.74",
4
4
  "description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
5
5
  "repository": {
6
6
  "type": "git",