coderifts 3.1.0 → 3.2.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/README.md CHANGED
@@ -138,6 +138,53 @@ After upgrading the `coderifts` CLI, re-run `coderifts hook install` so the
138
138
  installed hook matches the package (already-installed hooks are not
139
139
  auto-updated).
140
140
 
141
+ ### Claude Code PreToolUse (`coderifts claude-hook`) — ID824
142
+
143
+ Tool-call-time gate for **Claude Code**: blocks contract-touching `Write` /
144
+ `Edit` / `MultiEdit` when authorize preflight returns **BLOCK/STOP**.
145
+
146
+ **Exit map (Claude Code semantics — fixed):**
147
+
148
+ | Exit | Meaning |
149
+ |------|---------|
150
+ | **2** | **BLOCK** — tool call cancelled; stderr is shown to the model |
151
+ | **0** | Allow, or soft-skip (no key / wrong file / parse gap / API down) |
152
+ | **1** | **Never used for deny** — Claude treats exit 1 as non-blocking (action proceeds) |
153
+
154
+ Push-time equivalent (git exit **1** on BLOCK): `coderifts hook install`.
155
+
156
+ **Install path:** `npm i -g coderifts` then set key + spec (same as the git hook):
157
+
158
+ ```bash
159
+ git config coderifts.apiKey 'cr_live_…' # or: coderifts login / CODERIFTS_API_KEY
160
+ git config coderifts.specPath api/openapi.yaml # default if omitted
161
+ ```
162
+
163
+ **Recipe — project** (`.claude/settings.json`) **or user** (`~/.claude/settings.json`):
164
+
165
+ ```json
166
+ {
167
+ "hooks": {
168
+ "PreToolUse": [
169
+ {
170
+ "matcher": "Write|Edit|MultiEdit",
171
+ "hooks": [
172
+ {
173
+ "type": "command",
174
+ "command": "coderifts claude-hook",
175
+ "timeout": 60
176
+ }
177
+ ]
178
+ }
179
+ ]
180
+ }
181
+ }
182
+ ```
183
+
184
+ - **Project** `.claude/settings.json` — shared with the repo (commit if the team wants the gate).
185
+ - **User** `~/.claude/settings.json` — personal; applies to all projects on this machine.
186
+ - Matcher is case-sensitive tool names/regex. Exit-2 blocking is **Claude Code** semantics; the same decision discipline at push-time is the git pre-push hook above.
187
+
141
188
  ### Agent host files (`coderifts agent-setup`)
142
189
 
143
190
  Writes the six CodeRifts agent-host rule files into the current repo (or `--out <dir>`):
package/bin/coderifts.js CHANGED
@@ -109,6 +109,7 @@ program
109
109
  .option('--branch <name>', 'Branch to protect (default: repo default branch)')
110
110
  .option('--repo <owner/repo>', 'Override owner/repo (default: git remote origin)')
111
111
  .option('--apply', 'Apply the protection change (default: print the exact gh command only)')
112
+ .option('--enforce-admins', 'Set enforce_admins:true on create (default: false — admins can bypass; gate reports admin_bypass_open)')
112
113
  .option('--json', 'Machine-readable JSON result')
113
114
  .action(async (options) => {
114
115
  const { runSetupRequiredCheck } = require('../src/commands/setup-required-check');
@@ -201,6 +202,48 @@ program
201
202
  }
202
203
  });
203
204
 
205
+ // ── outcome — POST /api/v1/outcomes (caller assertion; does not verify the deploy) ──
206
+ program
207
+ .command('outcome <kind>')
208
+ .description(
209
+ 'Report a post-hoc outcome for a decision_id (deploy_succeeded/deploy_failed/…). '
210
+ + 'Records the caller\'s assertion — does not verify the deploy itself.',
211
+ )
212
+ .option('--decision <decision_id>', 'Decision id from decision_result.decision_id (required)')
213
+ .option('--observed-at <iso>', 'When the outcome was observed (ISO-8601; default: now)')
214
+ .option('--details <json>', 'Optional JSON object/array bound to the outcome row')
215
+ .option('--json', 'Print the API response JSON to stdout')
216
+ .addHelpText('after', () => {
217
+ const { USAGE } = require('../src/commands/outcome');
218
+ return `\n${USAGE}\n`;
219
+ })
220
+ .action(async (kind, options) => {
221
+ const { runOutcome } = require('../src/commands/outcome');
222
+ const result = await runOutcome(kind, options);
223
+ if (result && typeof result.exitCode === 'number') {
224
+ process.exitCode = result.exitCode;
225
+ }
226
+ });
227
+
228
+ // ── claude-hook — Claude Code PreToolUse (exit 2 = BLOCK; never exit 1 for deny) ──
229
+ program
230
+ .command('claude-hook')
231
+ .description(
232
+ 'Claude Code PreToolUse gate: block contract-touching Write|Edit|MultiEdit on BLOCK/STOP '
233
+ + '(exit 2). Soft-allow when unconfigured/unparseable/API down.',
234
+ )
235
+ .addHelpText('after', () => {
236
+ const { USAGE } = require('../src/commands/claude-hook');
237
+ return `\n${USAGE}\n`;
238
+ })
239
+ .action(async () => {
240
+ const { runClaudeHook } = require('../src/commands/claude-hook');
241
+ const result = await runClaudeHook();
242
+ if (result && typeof result.exitCode === 'number') {
243
+ process.exitCode = result.exitCode;
244
+ }
245
+ });
246
+
204
247
  // ── hook command group ──
205
248
  const hookCmd = program
206
249
  .command('hook')
package/dist/cli.js CHANGED
@@ -3007,7 +3007,7 @@ var require_package = __commonJS({
3007
3007
  "package.json"(exports2, module2) {
3008
3008
  module2.exports = {
3009
3009
  name: "coderifts",
3010
- version: "3.1.0",
3010
+ version: "3.2.0",
3011
3011
  description: "Detect breaking API changes from the command line. Works locally or with the CodeRifts cloud API.",
3012
3012
  author: "CodeRifts <hello@coderifts.com>",
3013
3013
  license: "MIT",
@@ -3045,7 +3045,7 @@ var require_package = __commonJS({
3045
3045
  },
3046
3046
  scripts: {
3047
3047
  build: "node scripts/copy-corpus.js && esbuild bin/coderifts.js --bundle --platform=node --target=node18 --outfile=dist/cli.js",
3048
- prepublishOnly: "npm run build",
3048
+ prepublishOnly: "bash ../../scripts/freeze-gate.sh && npm run build",
3049
3049
  postinstall: "node scripts/postinstall.js",
3050
3050
  test: "node --test test/*.test.js"
3051
3051
  },
@@ -13553,11 +13553,33 @@ var require_cloud = __commonJS({
13553
13553
  const q = encodeURIComponent(String(repo || ""));
13554
13554
  return cloudRequest("GET", `/api/v1/lock?repo=${q}`, apiKey);
13555
13555
  }
13556
+ function cloudPostOutcome(apiKey, body) {
13557
+ return cloudRequest("POST", "/api/v1/outcomes", apiKey, body);
13558
+ }
13559
+ function cloudAuthorizePreflight(before, after, apiKey, ctx = {}) {
13560
+ return cloudRequest("POST", "/api/v1/preflight", apiKey, {
13561
+ preflight_mode: "authorize",
13562
+ artifacts: [
13563
+ {
13564
+ id: "api",
13565
+ type: "openapi",
13566
+ before: String(before == null ? "" : before),
13567
+ after: String(after == null ? "" : after)
13568
+ }
13569
+ ],
13570
+ context: {
13571
+ operation: ctx.operation || "merge",
13572
+ environment: ctx.environment || "staging"
13573
+ }
13574
+ });
13575
+ }
13556
13576
  module2.exports = {
13557
13577
  cloudDiff,
13558
13578
  cloudRequest,
13559
13579
  cloudGetEnforcementStatus,
13560
13580
  cloudGetLock,
13581
+ cloudPostOutcome,
13582
+ cloudAuthorizePreflight,
13561
13583
  API_BASE
13562
13584
  };
13563
13585
  }
@@ -69056,6 +69078,24 @@ var require_deploy_gate2 = __commonJS({
69056
69078
  return null;
69057
69079
  }
69058
69080
  }
69081
+ function extractDecisionIdFromReceipt(receipt) {
69082
+ if (!receipt || typeof receipt !== "object") return null;
69083
+ if (typeof receipt.decision_id === "string" && receipt.decision_id.trim()) {
69084
+ return receipt.decision_id.trim();
69085
+ }
69086
+ const nested = receipt.decision_result;
69087
+ if (nested && typeof nested === "object" && typeof nested.decision_id === "string" && nested.decision_id.trim()) {
69088
+ return nested.decision_id.trim();
69089
+ }
69090
+ return null;
69091
+ }
69092
+ function formatOutcomeReportHint(decisionId) {
69093
+ const id = String(decisionId);
69094
+ return [
69095
+ "# report the deploy outcome after your deploy step:",
69096
+ `coderifts outcome <deploy_succeeded|deploy_failed|rolled_back> --decision ${id}`
69097
+ ].join("\n");
69098
+ }
69059
69099
  function renderDeployGateTerminal(bind, enforce) {
69060
69100
  const g = bind.gate;
69061
69101
  const color = bind.deploy_check_status === "success" ? chalk.green : bind.deploy_check_status === "failure" ? chalk.red : chalk.yellow;
@@ -69085,10 +69125,23 @@ var require_deploy_gate2 = __commonJS({
69085
69125
  const observed = observeCDEnforcement({ enforce });
69086
69126
  const bind = deployBind({ environment, artifact_id: artifactId, receipt, observed_cd_enforcement: observed });
69087
69127
  const code = clampExit(bind.deploy_check_status, enforce);
69128
+ const decisionId = extractDecisionIdFromReceipt(receipt);
69088
69129
  if (options.json) {
69089
- console.log(renderJson({ command: "deploy-gate", environment, artifact_id: artifactId, phase: enforce ? "enforcing" : "advisory", exit_code: code, ...bind }));
69130
+ console.log(renderJson({
69131
+ command: "deploy-gate",
69132
+ environment,
69133
+ artifact_id: artifactId,
69134
+ phase: enforce ? "enforcing" : "advisory",
69135
+ exit_code: code,
69136
+ decision_id: decisionId,
69137
+ ...bind
69138
+ }));
69090
69139
  } else {
69091
69140
  console.log(renderDeployGateTerminal(bind, enforce));
69141
+ if (decisionId) {
69142
+ console.log(formatOutcomeReportHint(decisionId));
69143
+ console.log("");
69144
+ }
69092
69145
  }
69093
69146
  process.exit(code);
69094
69147
  }
@@ -69098,6 +69151,8 @@ var require_deploy_gate2 = __commonJS({
69098
69151
  observeCDEnforcement,
69099
69152
  clampExit,
69100
69153
  readReceiptFile,
69154
+ extractDecisionIdFromReceipt,
69155
+ formatOutcomeReportHint,
69101
69156
  renderDeployGateTerminal
69102
69157
  };
69103
69158
  }
@@ -95855,13 +95910,30 @@ var require_setup_required_check = __commonJS({
95855
95910
  if (!protection || typeof protection !== "object") return [];
95856
95911
  const rsc = protection.required_status_checks;
95857
95912
  if (!rsc || typeof rsc !== "object") return [];
95858
- if (Array.isArray(rsc.contexts) && rsc.contexts.length) {
95859
- return rsc.contexts.map((c) => String(c));
95913
+ const raw = rsc.contexts || (Array.isArray(rsc.checks) ? rsc.checks.map((c) => c && c.context) : []);
95914
+ if (!Array.isArray(raw)) return [];
95915
+ return raw.map((c) => c == null ? "" : String(c)).filter(Boolean);
95916
+ }
95917
+ var HINT_403_PLAN_LIMIT = "private repo on a free plan: branch protection requires GitHub Pro or a public repo";
95918
+ var HINT_403_PERMISSION = "administration:read (or repo admin) required to read branch protection \u2014 try: gh auth refresh -s repo";
95919
+ var HINT_403_BOTH = "cannot distinguish cause from the response body \u2014 either (1) private repo on a free plan (branch protection needs GitHub Pro or a public repo) or (2) missing administration:read / repo admin (try: gh auth refresh -s repo)";
95920
+ function github403EvidenceText(read) {
95921
+ const parts = [];
95922
+ const body = read && read.body;
95923
+ if (body && typeof body === "object" && body.message != null) parts.push(String(body.message));
95924
+ if (typeof body === "string" && body.trim()) parts.push(body);
95925
+ if (read && read.errorMessage) parts.push(String(read.errorMessage));
95926
+ return parts.join("\n");
95927
+ }
95928
+ function classify403Cause(read) {
95929
+ const text = github403EvidenceText(read || {});
95930
+ if (/Upgrade to GitHub Pro|make this repository public/i.test(text)) {
95931
+ return { cause: "plan_limit", permission_hint: HINT_403_PLAN_LIMIT };
95860
95932
  }
95861
- if (Array.isArray(rsc.checks)) {
95862
- return rsc.checks.map((c) => c && c.context != null ? String(c.context) : "").filter(Boolean);
95933
+ if (/Resource not accessible|insufficient.*scope|not have permission|Must have admin|Requires authentication|Bad credentials|admin(istration)?:?(read|write)?/i.test(text) && !/Upgrade to GitHub Pro/i.test(text)) {
95934
+ return { cause: "permission", permission_hint: HINT_403_PERMISSION };
95863
95935
  }
95864
- return [];
95936
+ return { cause: "unknown", permission_hint: HINT_403_BOTH };
95865
95937
  }
95866
95938
  function classifyObservation(read, contextName = CHECK_NAME) {
95867
95939
  const status = read && read.status != null ? Number(read.status) : null;
@@ -95875,13 +95947,15 @@ var require_setup_required_check = __commonJS({
95875
95947
  };
95876
95948
  }
95877
95949
  if (status === 403) {
95950
+ const disc = classify403Cause(read);
95878
95951
  return {
95879
95952
  state: "UNKNOWN",
95880
95953
  context_is_required: false,
95881
95954
  required_contexts: [],
95882
95955
  protection: null,
95883
95956
  observation_error: "403",
95884
- permission_hint: "administration:read (or repo admin) required to read branch protection"
95957
+ observation_cause: disc.cause,
95958
+ permission_hint: disc.permission_hint
95885
95959
  };
95886
95960
  }
95887
95961
  if (status != null && status >= 400) {
@@ -96225,11 +96299,25 @@ var require_setup_required_check = __commonJS({
96225
96299
  if (!options.json) {
96226
96300
  logErr(chalk.red("Cannot observe branch protection (UNKNOWN)."));
96227
96301
  logErr(` HTTP: ${obs.observation_error || "unknown"}`);
96228
- logErr(` ${obs.permission_hint || "An admin with administration:read must run this command."}`);
96229
- logErr(" The CodeRifts App never writes protection; an admin must grant or run this.");
96302
+ logErr(` ${obs.permission_hint || HINT_403_BOTH}`);
96303
+ if (obs.observation_cause === "plan_limit") {
96304
+ logErr(" Not a missing-permission issue \u2014 the free-plan private-repo limit blocks classic protection reads.");
96305
+ } else if (obs.observation_cause === "permission") {
96306
+ logErr(" The CodeRifts App never writes protection; an admin must grant or run this.");
96307
+ } else {
96308
+ logErr(" The CodeRifts App never writes protection; fix plan limit or admin access, then re-run.");
96309
+ }
96230
96310
  }
96231
- return finish(EXIT.PERMISSION, { ...basePayload, ok: false, code: "PERMISSION", observation_error: obs.observation_error });
96311
+ return finish(EXIT.PERMISSION, {
96312
+ ...basePayload,
96313
+ ok: false,
96314
+ code: "PERMISSION",
96315
+ observation_error: obs.observation_error,
96316
+ observation_cause: obs.observation_cause || null,
96317
+ permission_hint: obs.permission_hint || null
96318
+ });
96232
96319
  }
96320
+ const enforceAdmins = options.enforceAdmins === true;
96233
96321
  let payload;
96234
96322
  if (obs.state === "ABSENT" || !obs.protection) {
96235
96323
  payload = {
@@ -96237,12 +96325,13 @@ var require_setup_required_check = __commonJS({
96237
96325
  strict: false,
96238
96326
  contexts: [CHECK_NAME]
96239
96327
  },
96240
- enforce_admins: false,
96328
+ enforce_admins: enforceAdmins,
96241
96329
  required_pull_request_reviews: null,
96242
96330
  restrictions: null
96243
96331
  };
96244
96332
  } else {
96245
96333
  payload = buildProtectionUpdatePayload(obs.protection, CHECK_NAME);
96334
+ if (enforceAdmins) payload.enforce_admins = true;
96246
96335
  }
96247
96336
  const cmdText = printApplyCommand(owner, repo, branch, payload);
96248
96337
  if (!options.apply) {
@@ -96251,6 +96340,11 @@ var require_setup_required_check = __commonJS({
96251
96340
  log(` repo: ${owner}/${repo}`);
96252
96341
  log(` branch: ${branch}`);
96253
96342
  log("");
96343
+ log("enforce_admins (admin bypass of the required check):");
96344
+ log(" true: admins cannot bypass the contract gate (closes the admin_bypass_open residual)");
96345
+ log(" false (default): admins can bypass; the gate reports admin_bypass_open");
96346
+ log(` this run: ${enforceAdmins ? "true (--enforce-admins)" : "false (default; pass --enforce-admins to set true)"}`);
96347
+ log("");
96254
96348
  log("Dry-run (default). Exact command to add the required check with your credentials:");
96255
96349
  log("");
96256
96350
  log(cmdText);
@@ -96261,6 +96355,7 @@ var require_setup_required_check = __commonJS({
96261
96355
  return finish(EXIT.NEEDS_APPLY, {
96262
96356
  ...basePayload,
96263
96357
  code: "NEEDS_APPLY",
96358
+ enforce_admins: enforceAdmins,
96264
96359
  apply_command: cmdText,
96265
96360
  apply_payload: payload
96266
96361
  });
@@ -96336,6 +96431,11 @@ var require_setup_required_check = __commonJS({
96336
96431
  EXIT,
96337
96432
  extractRequiredContexts,
96338
96433
  classifyObservation,
96434
+ classify403Cause,
96435
+ github403EvidenceText,
96436
+ HINT_403_PLAN_LIMIT,
96437
+ HINT_403_PERMISSION,
96438
+ HINT_403_BOTH,
96339
96439
  buildProtectionUpdatePayload,
96340
96440
  detectRulesetRequiredCheck,
96341
96441
  parseGitHubRemote,
@@ -97541,6 +97641,437 @@ var require_lock = __commonJS({
97541
97641
  }
97542
97642
  });
97543
97643
 
97644
+ // src/commands/outcome.js
97645
+ var require_outcome = __commonJS({
97646
+ "src/commands/outcome.js"(exports2, module2) {
97647
+ "use strict";
97648
+ var chalk = require_source();
97649
+ var { getApiKey } = require_config();
97650
+ var { cloudPostOutcome } = require_cloud();
97651
+ if (process.env.NO_COLOR) chalk.level = 0;
97652
+ var OUTCOME_KINDS = Object.freeze([
97653
+ "deploy_succeeded",
97654
+ "deploy_failed",
97655
+ "rolled_back",
97656
+ "consumer_break_reported",
97657
+ "remediation_verified_working",
97658
+ "false_positive_reported",
97659
+ "other_reported"
97660
+ ]);
97661
+ var OUTCOME_KIND_SET = new Set(OUTCOME_KINDS);
97662
+ var USAGE = [
97663
+ "Usage: coderifts outcome <kind> --decision <decision_id> [--observed-at <ISO>] [--details <json>] [--json]",
97664
+ "",
97665
+ "Report a post-hoc observed outcome for a past decision_id to the CodeRifts cloud API.",
97666
+ "This records the CALLER'S assertion (e.g. your deploy job knows success/failure) \u2014",
97667
+ "the command does NOT itself verify that a deploy succeeded or failed.",
97668
+ "",
97669
+ "kind (required, closed set):",
97670
+ ` ${OUTCOME_KINDS.join(", ")}`,
97671
+ "",
97672
+ "Requires a cloud API key (coderifts login or CODERIFTS_API_KEY).",
97673
+ "POST /api/v1/outcomes \u2014 source is always reported (server-side); reporter is derived from the key."
97674
+ ].join("\n");
97675
+ function isValidOutcomeKind(kind) {
97676
+ return typeof kind === "string" && OUTCOME_KIND_SET.has(kind);
97677
+ }
97678
+ function isValidObservedAt(iso) {
97679
+ if (typeof iso !== "string" || !iso.trim()) return false;
97680
+ const t = Date.parse(iso);
97681
+ return Number.isFinite(t);
97682
+ }
97683
+ function parseDetails(raw) {
97684
+ if (raw == null || raw === "") return { ok: true, details: null };
97685
+ if (typeof raw !== "string") return { ok: false, error: "--details must be a JSON string" };
97686
+ try {
97687
+ const v = JSON.parse(raw);
97688
+ if (v !== null && typeof v !== "object") {
97689
+ return { ok: false, error: "--details must be a JSON object or array" };
97690
+ }
97691
+ return { ok: true, details: v };
97692
+ } catch (e) {
97693
+ return { ok: false, error: `--details is not valid JSON: ${e && e.message || "parse error"}` };
97694
+ }
97695
+ }
97696
+ async function runOutcome(kind, options = {}, deps = {}) {
97697
+ const getKey = deps.getApiKey || getApiKey;
97698
+ const postOutcome = deps.cloudPostOutcome || cloudPostOutcome;
97699
+ const log = deps.log || console.log;
97700
+ const errLog = deps.errLog || console.error;
97701
+ const nowIso = deps.nowIso || (() => (/* @__PURE__ */ new Date()).toISOString());
97702
+ if (kind == null || String(kind).trim() === "") {
97703
+ errLog(chalk.red("Error: missing outcome kind"));
97704
+ errLog(USAGE);
97705
+ return { exitCode: 1, error: "missing_kind" };
97706
+ }
97707
+ const outcomeKind = String(kind).trim();
97708
+ if (!isValidOutcomeKind(outcomeKind)) {
97709
+ errLog(chalk.red(`Error: invalid outcome kind '${outcomeKind}'`));
97710
+ errLog(chalk.dim(` Valid kinds: ${OUTCOME_KINDS.join(", ")}`));
97711
+ return { exitCode: 1, error: "invalid_kind" };
97712
+ }
97713
+ const decisionId = options.decision != null ? String(options.decision).trim() : "";
97714
+ if (!decisionId) {
97715
+ errLog(chalk.red("Error: --decision <decision_id> is required"));
97716
+ errLog(USAGE);
97717
+ return { exitCode: 1, error: "missing_decision" };
97718
+ }
97719
+ let observedAt;
97720
+ if (options.observedAt != null && String(options.observedAt).trim() !== "") {
97721
+ observedAt = String(options.observedAt).trim();
97722
+ if (!isValidObservedAt(observedAt)) {
97723
+ errLog(chalk.red("Error: --observed-at must be a valid ISO-8601 timestamp"));
97724
+ return { exitCode: 1, error: "invalid_observed_at" };
97725
+ }
97726
+ } else {
97727
+ observedAt = nowIso();
97728
+ }
97729
+ const det = parseDetails(options.details);
97730
+ if (!det.ok) {
97731
+ errLog(chalk.red(`Error: ${det.error}`));
97732
+ return { exitCode: 1, error: "invalid_details" };
97733
+ }
97734
+ const apiKey = getKey();
97735
+ if (!apiKey) {
97736
+ errLog(chalk.red("Error: no API key. Run `coderifts login` or set CODERIFTS_API_KEY."));
97737
+ return { exitCode: 1, error: "missing_api_key" };
97738
+ }
97739
+ const body = {
97740
+ decision_id: decisionId,
97741
+ outcome_kind: outcomeKind,
97742
+ observed_at: observedAt
97743
+ };
97744
+ if (det.details != null) body.details = det.details;
97745
+ let response;
97746
+ try {
97747
+ response = await postOutcome(apiKey, body);
97748
+ } catch (e) {
97749
+ const msg = e && e.message ? String(e.message) : "request failed";
97750
+ errLog(chalk.red(`Error: ${msg}`));
97751
+ if (e && e.statusCode) errLog(chalk.dim(` (HTTP ${e.statusCode})`));
97752
+ if (e && e.code) errLog(chalk.dim(` (${e.code})`));
97753
+ return { exitCode: 1, error: msg, body };
97754
+ }
97755
+ if (options.json) {
97756
+ log(JSON.stringify(response, null, 2));
97757
+ } else {
97758
+ const o = response && response.outcome ? response.outcome : response;
97759
+ log(chalk.bold("CodeRifts outcome recorded"));
97760
+ log(` kind: ${outcomeKind}`);
97761
+ log(` decision_id: ${decisionId}`);
97762
+ log(` observed_at: ${observedAt}`);
97763
+ if (o && o.id) log(` id: ${o.id}`);
97764
+ if (response && response.source) log(` source: ${response.source}`);
97765
+ log(chalk.dim(" Caller assertion only \u2014 this command did not verify the deploy."));
97766
+ }
97767
+ return { exitCode: 0, response, body };
97768
+ }
97769
+ module2.exports = {
97770
+ runOutcome,
97771
+ isValidOutcomeKind,
97772
+ isValidObservedAt,
97773
+ parseDetails,
97774
+ OUTCOME_KINDS,
97775
+ USAGE
97776
+ };
97777
+ }
97778
+ });
97779
+
97780
+ // src/commands/claude-hook.js
97781
+ var require_claude_hook = __commonJS({
97782
+ "src/commands/claude-hook.js"(exports2, module2) {
97783
+ "use strict";
97784
+ var fs = require("fs");
97785
+ var path = require("path");
97786
+ var { execSync } = require("child_process");
97787
+ var { getApiKey } = require_config();
97788
+ var { cloudAuthorizePreflight } = require_cloud();
97789
+ var DEFAULT_SPEC_PATH = "api/openapi.yaml";
97790
+ var CLOSED_ACTIONS = /* @__PURE__ */ new Set([
97791
+ "CONTINUE",
97792
+ "CONTINUE_WITH_MONITORING",
97793
+ "REQUEST_APPROVAL",
97794
+ "STOP"
97795
+ ]);
97796
+ var USAGE = [
97797
+ "Usage: coderifts claude-hook",
97798
+ "",
97799
+ "Claude Code PreToolUse hook: gates Write|Edit|MultiEdit when they touch the",
97800
+ "configured contract spec path. Reads JSON context from STDIN.",
97801
+ "",
97802
+ "Exit map (Claude Code semantics \u2014 get this exactly right):",
97803
+ " 2 BLOCK \u2014 tool call cancelled (decision BLOCK/STOP or unrecognised execution_action)",
97804
+ " 0 allow \u2014 or soft-skip (see soft contracts)",
97805
+ " 1 NEVER used for security deny (Claude treats exit 1 as non-blocking; action proceeds)",
97806
+ "",
97807
+ "Soft contracts (exit 0 + one-line stderr note \u2014 do not brick the session):",
97808
+ " no API key (git config coderifts.apiKey / coderifts login / CODERIFTS_API_KEY)",
97809
+ " unparseable/unknown stdin shape",
97810
+ " file_path is not the configured spec path",
97811
+ " Edit/MultiEdit cannot be applied cleanly to disk content",
97812
+ " API unreachable / network error",
97813
+ "",
97814
+ "Spec path (same source as git pre-push hook):",
97815
+ " git config coderifts.specPath (default: api/openapi.yaml)",
97816
+ "",
97817
+ "Baseline at PreToolUse: before = current file on disk (pre-edit state \u2014 the tool has",
97818
+ "not written yet). after = proposed content from tool_input (Write: content; Edit/MultiEdit:",
97819
+ "old\u2192new applied to disk content).",
97820
+ "",
97821
+ "Push-time equivalent: coderifts hook install (git exit 1 on BLOCK)."
97822
+ ].join("\n");
97823
+ function readGitConfig(key, deps = {}) {
97824
+ const run = deps.execSync || execSync;
97825
+ const cwd = deps.cwd || process.cwd();
97826
+ try {
97827
+ const v = run(`git config ${key}`, { encoding: "utf8", cwd, stdio: ["ignore", "pipe", "pipe"] });
97828
+ const t = String(v || "").trim();
97829
+ return t || null;
97830
+ } catch {
97831
+ return null;
97832
+ }
97833
+ }
97834
+ function resolveApiKey(deps = {}) {
97835
+ const getKey = deps.getApiKey || getApiKey;
97836
+ const fromLogin = getKey();
97837
+ if (fromLogin) return fromLogin;
97838
+ const fromEnv = process.env.CODERIFTS_API_KEY && String(process.env.CODERIFTS_API_KEY).trim();
97839
+ if (fromEnv) return fromEnv;
97840
+ return readGitConfig("coderifts.apiKey", deps);
97841
+ }
97842
+ function resolveSpecPath(deps = {}) {
97843
+ const fromGit = readGitConfig("coderifts.specPath", deps);
97844
+ if (fromGit) return fromGit;
97845
+ return DEFAULT_SPEC_PATH;
97846
+ }
97847
+ function parseStdinJson(raw) {
97848
+ if (raw == null || String(raw).trim() === "") {
97849
+ return { ok: false, reason: "empty stdin" };
97850
+ }
97851
+ let obj;
97852
+ try {
97853
+ obj = JSON.parse(String(raw));
97854
+ } catch {
97855
+ return { ok: false, reason: "stdin is not JSON" };
97856
+ }
97857
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
97858
+ return { ok: false, reason: "stdin JSON is not an object" };
97859
+ }
97860
+ const toolName = obj.tool_name || obj.toolName || obj.name || null;
97861
+ let toolInput = obj.tool_input || obj.toolInput || obj.input || null;
97862
+ if (toolInput == null && obj.file_path) toolInput = obj;
97863
+ if (!toolName || typeof toolName !== "string") {
97864
+ return { ok: false, reason: "missing tool_name" };
97865
+ }
97866
+ if (!toolInput || typeof toolInput !== "object") {
97867
+ return { ok: false, reason: "missing tool_input" };
97868
+ }
97869
+ return { ok: true, toolName: String(toolName), toolInput };
97870
+ }
97871
+ function isSpecPath(filePath, specPath) {
97872
+ if (!filePath || !specPath) return false;
97873
+ const fp = path.normalize(String(filePath).replace(/\\/g, "/"));
97874
+ const sp = path.normalize(String(specPath).replace(/\\/g, "/"));
97875
+ if (fp === sp) return true;
97876
+ if (fp.endsWith("/" + sp) || fp.endsWith(sp)) return true;
97877
+ const baseFp = path.basename(fp);
97878
+ const baseSp = path.basename(sp);
97879
+ if (baseFp === baseSp && (fp.endsWith(sp) || sp.endsWith(baseSp))) {
97880
+ const tail = sp.split("/").filter(Boolean).join("/");
97881
+ return fp.replace(/\\/g, "/").endsWith(tail);
97882
+ }
97883
+ return false;
97884
+ }
97885
+ function deriveAfterContent(toolName, toolInput, diskBefore) {
97886
+ const name = String(toolName || "");
97887
+ if (name === "Write" || name === "write") {
97888
+ if (typeof toolInput.content !== "string") {
97889
+ return { ok: false, reason: "Write tool_input.content missing or not a string" };
97890
+ }
97891
+ return { ok: true, after: toolInput.content };
97892
+ }
97893
+ if (name === "Edit" || name === "edit") {
97894
+ const oldS = toolInput.old_string != null ? toolInput.old_string : toolInput.oldString;
97895
+ const newS = toolInput.new_string != null ? toolInput.new_string : toolInput.newString;
97896
+ if (typeof oldS !== "string" || typeof newS !== "string") {
97897
+ return { ok: false, reason: "Edit tool_input.old_string/new_string missing" };
97898
+ }
97899
+ if (!diskBefore.includes(oldS)) {
97900
+ return { ok: false, reason: "Edit old_string not found in disk content" };
97901
+ }
97902
+ return { ok: true, after: diskBefore.replace(oldS, newS) };
97903
+ }
97904
+ if (name === "MultiEdit" || name === "multi_edit" || name === "multiEdit") {
97905
+ const edits = toolInput.edits || toolInput.Edits;
97906
+ if (!Array.isArray(edits) || edits.length === 0) {
97907
+ return { ok: false, reason: "MultiEdit tool_input.edits missing or empty" };
97908
+ }
97909
+ let cur = diskBefore;
97910
+ for (let i = 0; i < edits.length; i++) {
97911
+ const e = edits[i] || {};
97912
+ const oldS = e.old_string != null ? e.old_string : e.oldString;
97913
+ const newS = e.new_string != null ? e.new_string : e.newString;
97914
+ if (typeof oldS !== "string" || typeof newS !== "string") {
97915
+ return { ok: false, reason: `MultiEdit edits[${i}] missing old_string/new_string` };
97916
+ }
97917
+ if (!cur.includes(oldS)) {
97918
+ return { ok: false, reason: `MultiEdit edits[${i}] old_string not found in content` };
97919
+ }
97920
+ cur = cur.replace(oldS, newS);
97921
+ }
97922
+ return { ok: true, after: cur };
97923
+ }
97924
+ return { ok: false, reason: `unsupported tool_name for content derive: ${name}` };
97925
+ }
97926
+ function mapDecisionSeverity(result) {
97927
+ const d = result && typeof result === "object" ? result : {};
97928
+ let ea = null;
97929
+ const dr = d.decision_result;
97930
+ if (dr && typeof dr === "object" && typeof dr.execution_action === "string") {
97931
+ ea = dr.execution_action;
97932
+ } else if (typeof d.execution_action === "string") {
97933
+ ea = d.execution_action;
97934
+ }
97935
+ let severity;
97936
+ if (ea != null && ea !== "") {
97937
+ if (!CLOSED_ACTIONS.has(ea)) {
97938
+ severity = "UNKNOWN";
97939
+ } else if (ea === "CONTINUE" || ea === "CONTINUE_WITH_MONITORING") {
97940
+ severity = "ALLOW";
97941
+ } else if (ea === "STOP") {
97942
+ severity = "BLOCK";
97943
+ } else {
97944
+ severity = "REQUIRE_APPROVAL";
97945
+ }
97946
+ } else {
97947
+ const od = d.omega_decision || d.decision || "ALLOW";
97948
+ if (od === "BLOCK") severity = "BLOCK";
97949
+ else if (od === "REQUIRE_APPROVAL") severity = "REQUIRE_APPROVAL";
97950
+ else if (od === "WARN") severity = "WARN";
97951
+ else severity = "ALLOW";
97952
+ }
97953
+ const decision = dr && dr.decision || d.decision || d.omega_decision || null;
97954
+ const decisionId = dr && dr.decision_id || d.decision_id || null;
97955
+ return {
97956
+ severity,
97957
+ decision: decision != null ? String(decision) : null,
97958
+ decisionId: decisionId != null ? String(decisionId) : null,
97959
+ executionAction: ea
97960
+ };
97961
+ }
97962
+ async function runClaudeHook(options = {}, deps = {}) {
97963
+ const errLog = deps.errLog || ((m) => console.error(String(m)));
97964
+ const readStdin = deps.readStdin || (() => {
97965
+ try {
97966
+ return fs.readFileSync(0, "utf8");
97967
+ } catch {
97968
+ return "";
97969
+ }
97970
+ });
97971
+ const readFile = deps.readFile || ((p) => fs.readFileSync(p, "utf8"));
97972
+ const exists = deps.exists || ((p) => fs.existsSync(p));
97973
+ const authorize = deps.cloudAuthorizePreflight || cloudAuthorizePreflight;
97974
+ const cwd = deps.cwd || process.cwd();
97975
+ const apiKey = resolveApiKey({ ...deps, cwd });
97976
+ if (!apiKey) {
97977
+ errLog("CodeRifts claude-hook: no API key (coderifts login / CODERIFTS_API_KEY / git config coderifts.apiKey) \u2014 allowing");
97978
+ return { exitCode: 0, reason: "missing_api_key" };
97979
+ }
97980
+ const raw = typeof options.stdin === "string" ? options.stdin : readStdin();
97981
+ const parsed = parseStdinJson(raw);
97982
+ if (!parsed.ok) {
97983
+ errLog(`CodeRifts claude-hook: ${parsed.reason} \u2014 allowing (soft; never block on parse gap)`);
97984
+ return { exitCode: 0, reason: "stdin_unparseable" };
97985
+ }
97986
+ const { toolName, toolInput } = parsed;
97987
+ const filePath = toolInput.file_path || toolInput.filePath || toolInput.path;
97988
+ if (!filePath || typeof filePath !== "string") {
97989
+ errLog("CodeRifts claude-hook: tool_input.file_path missing \u2014 allowing (soft)");
97990
+ return { exitCode: 0, reason: "missing_file_path" };
97991
+ }
97992
+ const specPath = resolveSpecPath({ ...deps, cwd });
97993
+ if (!isSpecPath(filePath, specPath)) {
97994
+ return { exitCode: 0, reason: "not_spec_path" };
97995
+ }
97996
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
97997
+ let diskBefore = "";
97998
+ if (exists(absPath)) {
97999
+ try {
98000
+ diskBefore = readFile(absPath, "utf8");
98001
+ } catch (e) {
98002
+ errLog(`CodeRifts claude-hook: cannot read ${absPath} \u2014 allowing (soft)`);
98003
+ return { exitCode: 0, reason: "disk_unreadable" };
98004
+ }
98005
+ }
98006
+ const derived = deriveAfterContent(toolName, toolInput, diskBefore);
98007
+ if (!derived.ok) {
98008
+ errLog(`CodeRifts claude-hook: ${derived.reason} \u2014 allowing (soft; never guess edit apply)`);
98009
+ return { exitCode: 0, reason: "edit_apply_failed" };
98010
+ }
98011
+ if (diskBefore === derived.after) {
98012
+ return { exitCode: 0, reason: "identical" };
98013
+ }
98014
+ let result;
98015
+ try {
98016
+ result = await authorize(diskBefore, derived.after, apiKey, {
98017
+ operation: "merge",
98018
+ environment: "staging"
98019
+ });
98020
+ } catch (e) {
98021
+ const msg = e && e.message ? String(e.message) : "request failed";
98022
+ errLog(`CodeRifts claude-hook: API unreachable (${msg}) \u2014 allowing (soft; availability must not brick the editor)`);
98023
+ return { exitCode: 0, reason: "api_unreachable" };
98024
+ }
98025
+ const mapped = mapDecisionSeverity(result);
98026
+ const idPart = mapped.decisionId ? ` decision_id=${mapped.decisionId}` : "";
98027
+ const decPart = mapped.decision ? ` decision=${mapped.decision}` : "";
98028
+ const eaPart = mapped.executionAction ? ` execution_action=${mapped.executionAction}` : "";
98029
+ if (mapped.severity === "BLOCK" || mapped.severity === "UNKNOWN") {
98030
+ errLog(
98031
+ `CodeRifts claude-hook: BLOCKED${decPart}${eaPart}${idPart}` + (mapped.severity === "UNKNOWN" ? " (unrecognised execution_action)" : "")
98032
+ );
98033
+ return {
98034
+ exitCode: 2,
98035
+ reason: mapped.severity === "UNKNOWN" ? "unknown_action" : "block",
98036
+ severity: mapped.severity,
98037
+ decision: mapped.decision,
98038
+ decisionId: mapped.decisionId
98039
+ };
98040
+ }
98041
+ if (mapped.severity === "REQUIRE_APPROVAL" || mapped.severity === "WARN") {
98042
+ errLog(`CodeRifts claude-hook: WARNING ${mapped.severity}${decPart}${eaPart}${idPart}`);
98043
+ return {
98044
+ exitCode: 0,
98045
+ reason: "warn",
98046
+ severity: mapped.severity,
98047
+ decision: mapped.decision,
98048
+ decisionId: mapped.decisionId
98049
+ };
98050
+ }
98051
+ return {
98052
+ exitCode: 0,
98053
+ reason: "allow",
98054
+ severity: "ALLOW",
98055
+ decision: mapped.decision,
98056
+ decisionId: mapped.decisionId
98057
+ };
98058
+ }
98059
+ module2.exports = {
98060
+ runClaudeHook,
98061
+ parseStdinJson,
98062
+ isSpecPath,
98063
+ deriveAfterContent,
98064
+ mapDecisionSeverity,
98065
+ resolveApiKey,
98066
+ resolveSpecPath,
98067
+ readGitConfig,
98068
+ DEFAULT_SPEC_PATH,
98069
+ CLOSED_ACTIONS,
98070
+ USAGE
98071
+ };
98072
+ }
98073
+ });
98074
+
97544
98075
  // corpus/vectors-mcp-fpfn.json
97545
98076
  var require_vectors_mcp_fpfn = __commonJS({
97546
98077
  "corpus/vectors-mcp-fpfn.json"(exports2, module2) {
@@ -99460,7 +99991,7 @@ program.command("login").description("Save your API key for cloud features").act
99460
99991
  const { login } = require_login();
99461
99992
  await login();
99462
99993
  });
99463
- program.command("setup-required-check").description('Guide setup of required status check "CodeRifts / contract-gate" (uses gh, your credentials)').option("--branch <name>", "Branch to protect (default: repo default branch)").option("--repo <owner/repo>", "Override owner/repo (default: git remote origin)").option("--apply", "Apply the protection change (default: print the exact gh command only)").option("--json", "Machine-readable JSON result").action(async (options) => {
99994
+ program.command("setup-required-check").description('Guide setup of required status check "CodeRifts / contract-gate" (uses gh, your credentials)').option("--branch <name>", "Branch to protect (default: repo default branch)").option("--repo <owner/repo>", "Override owner/repo (default: git remote origin)").option("--apply", "Apply the protection change (default: print the exact gh command only)").option("--enforce-admins", "Set enforce_admins:true on create (default: false \u2014 admins can bypass; gate reports admin_bypass_open)").option("--json", "Machine-readable JSON result").action(async (options) => {
99464
99995
  const { runSetupRequiredCheck } = require_setup_required_check();
99465
99996
  const result = await runSetupRequiredCheck(options);
99466
99997
  if (result && typeof result.exitCode === "number") {
@@ -99506,6 +100037,34 @@ program.command("lock [repo]").description("Fetch the observed agent-contract lo
99506
100037
  process.exitCode = result.exitCode;
99507
100038
  }
99508
100039
  });
100040
+ program.command("outcome <kind>").description(
100041
+ "Report a post-hoc outcome for a decision_id (deploy_succeeded/deploy_failed/\u2026). Records the caller's assertion \u2014 does not verify the deploy itself."
100042
+ ).option("--decision <decision_id>", "Decision id from decision_result.decision_id (required)").option("--observed-at <iso>", "When the outcome was observed (ISO-8601; default: now)").option("--details <json>", "Optional JSON object/array bound to the outcome row").option("--json", "Print the API response JSON to stdout").addHelpText("after", () => {
100043
+ const { USAGE } = require_outcome();
100044
+ return `
100045
+ ${USAGE}
100046
+ `;
100047
+ }).action(async (kind, options) => {
100048
+ const { runOutcome } = require_outcome();
100049
+ const result = await runOutcome(kind, options);
100050
+ if (result && typeof result.exitCode === "number") {
100051
+ process.exitCode = result.exitCode;
100052
+ }
100053
+ });
100054
+ program.command("claude-hook").description(
100055
+ "Claude Code PreToolUse gate: block contract-touching Write|Edit|MultiEdit on BLOCK/STOP (exit 2). Soft-allow when unconfigured/unparseable/API down."
100056
+ ).addHelpText("after", () => {
100057
+ const { USAGE } = require_claude_hook();
100058
+ return `
100059
+ ${USAGE}
100060
+ `;
100061
+ }).action(async () => {
100062
+ const { runClaudeHook } = require_claude_hook();
100063
+ const result = await runClaudeHook();
100064
+ if (result && typeof result.exitCode === "number") {
100065
+ process.exitCode = result.exitCode;
100066
+ }
100067
+ });
99509
100068
  var hookCmd = program.command("hook").description("Manage the CodeRifts pre-push Git hook");
99510
100069
  hookCmd.command("install").description("Install the CodeRifts pre-push hook in the current Git repo").action(() => {
99511
100070
  const { install } = require_hook();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderifts",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Detect breaking API changes from the command line. Works locally or with the CodeRifts cloud API.",
5
5
  "author": "CodeRifts <hello@coderifts.com>",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-corpus.js && esbuild bin/coderifts.js --bundle --platform=node --target=node18 --outfile=dist/cli.js",
41
- "prepublishOnly": "npm run build",
41
+ "prepublishOnly": "bash ../../scripts/freeze-gate.sh && npm run build",
42
42
  "postinstall": "node scripts/postinstall.js",
43
43
  "test": "node --test test/*.test.js"
44
44
  },