pqcheck 0.16.15 → 0.16.17

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/bin/pqcheck.js +133 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
9
9
  [![license](https://img.shields.io/npm/l/pqcheck.svg?style=flat-square&color=06b6d4)](./LICENSE)
10
10
 
11
- > **Latest: v0.16.12** — ⚠️ **Existing GitHub Action users:** one-line fix required in `.github/workflows/cipherwake.yml` (`uses: cipherwakelabs/pqcheck@v3` `cipherwakelabs/pqcheck/action@v3`). The old ref was broken since v0.15 and silently failed every CI run; today's end-to-end test caught it. Re-running `pqcheck onboard` also regenerates the workflow correctly. Plus: README rewritten to advertise both `pqcheck <domain>` and `pqcheck deploy-check --ai` equally, rate limits corrected. [Full changelog →](./CHANGELOG.md)
11
+ > **Latest: v0.16.17** — fail-loud AI guard now covers the no-baseline first-deploy fallback. `pqcheck deploy-check <new-domain> --ai` previously exited 3 with bare `error: /api/scan returned 429` if the very first scan got rate-limited no `CIPHERWAKE_AI_GUARD_RESULT` block, leaving the calling AI agent with no `ship_decision` to route on. Now every error path in that fallback emits a `ship_decision=review` block with a status-specific `top_issue` code. [Full changelog →](./CHANGELOG.md)
12
12
 
13
13
  ## Two ways to use it
14
14
 
package/bin/pqcheck.js CHANGED
@@ -3051,12 +3051,51 @@ async function runScanBasedDeployCheck(domain, args) {
3051
3051
  try {
3052
3052
  resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
3053
3053
  } catch (err) {
3054
+ // v0.16.17 — the v0.16.13 fail-loud AI guard fix was applied to the main
3055
+ // trust-diff deploy-check path but missed THIS fallback path (no-baseline
3056
+ // first-deploy), so a network blip on the very first `pqcheck deploy-check
3057
+ // <new-domain> --ai` exited 3 with no CIPHERWAKE_AI_GUARD_RESULT block.
3058
+ // The calling AI agent then had no ship_decision to route on and could
3059
+ // silently continue shipping — exactly the failure mode the protocol
3060
+ // exists to prevent. Same emit-block-and-exit pattern as trust-diff.
3054
3061
  console.error(color("red", `error: network failure calling /api/scan: ${err.message}`));
3055
- process.exit(3);
3062
+ return emitAiGuardReviewAndExit(args, {
3063
+ code: "deploy_check_fetch_failed",
3064
+ message: `Network failure calling /api/scan: ${err?.message || "fetch failed"}`,
3065
+ exitCode: 3,
3066
+ });
3056
3067
  }
3057
3068
  if (!resp.ok) {
3058
- console.error(color("red", `error: /api/scan returned ${resp.status}`));
3059
- process.exit(3);
3069
+ // v0.16.17 same fix as above for the HTTP-non-OK path. The 429 case
3070
+ // is especially load-bearing: a brand new AI-coder workflow trying its
3071
+ // first deploy-check on a fresh project will commonly hit per-IP rate
3072
+ // limits, get a bare `error: /api/scan returned 429`, and exit 3 with no
3073
+ // guard block. The AI agent then has nothing to parse. Emitting a
3074
+ // ship_decision=review block with a quota-specific error code lets the
3075
+ // agent surface the rate-limit problem to the user instead of silently
3076
+ // continuing. Surface the body message + auth hint when available so
3077
+ // the user knows whether to wait or get an API key.
3078
+ const body = await safeJSON(resp);
3079
+ const statusLabel = resp.status === 429
3080
+ ? "rate-limited"
3081
+ : resp.status === 401 || resp.status === 403
3082
+ ? "auth failed"
3083
+ : `${resp.status}`;
3084
+ console.error(color("red", `error: /api/scan returned ${resp.status} (${statusLabel})`));
3085
+ if (body?.message) console.error(color("dim", body.message));
3086
+ if (body?.hint) console.error(color("dim", body.hint));
3087
+ if (resp.status === 429) {
3088
+ console.error(color("dim", "Higher quota via free API key (no card): https://cipherwake.io/account#api-keys"));
3089
+ }
3090
+ const code = resp.status === 429
3091
+ ? "deploy_check_rate_limited"
3092
+ : resp.status === 401 || resp.status === 403
3093
+ ? "deploy_check_auth_failed"
3094
+ : "deploy_check_scan_failed";
3095
+ const message = resp.status === 429
3096
+ ? (body?.message || "Per-IP rate limit hit on /api/scan. Wait ~1 minute or use an API key for higher quota.")
3097
+ : body?.message || `Cipherwake /api/scan returned ${resp.status}.`;
3098
+ return emitAiGuardReviewAndExit(args, { code, message, exitCode: 3 });
3060
3099
  }
3061
3100
  const report = await resp.json();
3062
3101
  const findings = Array.isArray(report.findings) ? report.findings : [];
@@ -4441,7 +4480,7 @@ function renderReleaseChecklist(domain, opts = {}) {
4441
4480
  // `pqcheck init` — interactive workflow scaffold (habit-loop #4, locked 2026-05-16)
4442
4481
  // =============================================================================
4443
4482
  // Writes a ready-to-commit .github/workflows/cipherwake.yml that calls
4444
- // cipherwakelabs/pqcheck/action@v3 in trust-diff mode. Zero copy-paste docs friction.
4483
+ // cipherwakelabs/pqcheck@v4 in trust-diff mode. Zero copy-paste docs friction.
4445
4484
  //
4446
4485
  // Flags:
4447
4486
  // --domain <d> Skip the domain prompt
@@ -4618,7 +4657,7 @@ jobs:
4618
4657
  runs-on: ubuntu-latest
4619
4658
  steps:
4620
4659
  - name: Run Cipherwake Trust Diff
4621
- uses: cipherwakelabs/pqcheck/action@v3
4660
+ uses: cipherwakelabs/pqcheck@v4
4622
4661
  with:
4623
4662
  mode: trust-diff
4624
4663
  domain: ${domain}
@@ -5613,9 +5652,23 @@ async function runProtocolCommand(args) {
5613
5652
  console.log("Here's what would be added:");
5614
5653
  console.log("");
5615
5654
  if (detected.length === 0) {
5655
+ // Default to creating project-local ./CLAUDE.md rather than global
5656
+ // ~/.claude/CLAUDE.md. Mirrors the cli/setup --scope=project default
5657
+ // (commit 4450e77): don't pollute global config when no signal exists
5658
+ // that the user wants machine-wide installation. Pass --scope=global
5659
+ // to override and create ~/.claude/CLAUDE.md instead.
5660
+ const scopeRaw = (parseFlag(args, "--scope") || "project").toLowerCase();
5661
+ const useGlobal = scopeRaw === "global";
5662
+ const fallbackPath = useGlobal
5663
+ ? path.join(os.homedir(), ".claude", "CLAUDE.md")
5664
+ : path.join(process.cwd(), "CLAUDE.md");
5665
+ const fallbackDisplay = fallbackPath.replace(os.homedir(), "~");
5616
5666
  console.log(color("dim", " No existing CLAUDE.md / .cursorrules / .aider.conf.yml found."));
5617
- console.log(color("dim", " Creating ~/.claude/CLAUDE.md with the protocol."));
5618
- detected.push({ label: "Claude Code (will create)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") });
5667
+ console.log(color("dim", ` Creating ${fallbackDisplay} with the protocol.`));
5668
+ if (!useGlobal) {
5669
+ console.log(color("dim", " (pass --scope global to create ~/.claude/CLAUDE.md instead)"));
5670
+ }
5671
+ detected.push({ label: useGlobal ? "Claude Code (will create global)" : "Claude Code (will create project)", path: fallbackPath });
5619
5672
  }
5620
5673
  for (const d of detected) {
5621
5674
  console.log(` • Append a ~30-line "## Pre-deploy verification with Cipherwake" section to ${color("bold", d.path)}`);
@@ -5927,11 +5980,21 @@ async function runSetupCommand(args) {
5927
5980
  const skipHook = args.includes("--skip-hook");
5928
5981
  const skipStatusline = args.includes("--skip-statusline");
5929
5982
  const skipVscode = args.includes("--skip-vscode");
5983
+ // Where to install Claude Code statusLine + hooks. Default 'project' keeps
5984
+ // Cipherwake scoped to the current repo (avoids the "Cipherwake badge
5985
+ // follows me into unrelated projects" UX bug). 'global' is opt-in for
5986
+ // power users who want one canonical domain badge across every project.
5987
+ const settingsScope = (parseFlag(args, "--scope") || "project").toLowerCase();
5988
+ if (!["project", "global"].includes(settingsScope)) {
5989
+ console.error(color("red", `error: --scope must be 'project' or 'global' (got '${settingsScope}')`));
5990
+ process.exit(3);
5991
+ }
5930
5992
 
5931
5993
  if (!domain) {
5932
5994
  console.error(color("red", "error: pqcheck setup requires --domain"));
5933
5995
  console.error(color("dim", "Usage: npx pqcheck setup --auto --domain example.com"));
5934
- console.error(color("dim", " --plan Print the install plan without writing any files"));
5996
+ console.error(color("dim", " --plan Print the install plan without writing any files"));
5997
+ console.error(color("dim", " --scope project|global Where to install Claude Code hooks (default: project — this repo only)"));
5935
5998
  console.error(color("dim", " --invoked-by=\"<name>\" --consent-phrase=\"<words>\" (audit trail)"));
5936
5999
  console.error(color("dim", "Skip flags: --skip-workflow --skip-protocol --skip-hook --skip-statusline --skip-vscode"));
5937
6000
  process.exit(3);
@@ -5977,11 +6040,17 @@ async function runSetupCommand(args) {
5977
6040
  }
5978
6041
  if (!skipHook) planEntries.push({ what: "git pre-push hook", to: path.join(process.cwd(), ".git", "hooks", "pre-push"), op: "create (if git repo)" });
5979
6042
  if (!skipStatusline) {
5980
- const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
6043
+ const settingsPath = settingsScope === "global"
6044
+ ? path.join(os.homedir(), ".claude", "settings.json")
6045
+ : path.join(process.cwd(), ".claude", "settings.json");
5981
6046
  let exists = false;
5982
6047
  try { await fs.access(settingsPath); exists = true; } catch { /* */ }
5983
- planEntries.push({ what: "Claude Code statusLine", to: settingsPath, op: exists ? "deep-merge (backup first)" : "create" });
6048
+ const scopeNote = settingsScope === "global"
6049
+ ? " [GLOBAL — fires in every project on this machine]"
6050
+ : " [PROJECT-LOCAL — fires only when Claude Code runs in this directory]";
6051
+ planEntries.push({ what: `Claude Code statusLine${scopeNote}`, to: settingsPath, op: exists ? "deep-merge (backup first)" : "create" });
5984
6052
  planEntries.push({ what: "Claude Code chat-hook (PostToolUse Bash)", to: settingsPath, op: exists ? "deep-merge into hooks.PostToolUse" : "create" });
6053
+ planEntries.push({ what: "Claude Code prompt-hook (UserPromptSubmit)", to: settingsPath, op: exists ? "deep-merge into hooks.UserPromptSubmit" : "create" });
5985
6054
  }
5986
6055
  if (!skipVscode) planEntries.push({ what: "VS Code / Cursor extension", to: "via `code --install-extension cipherwakelabs.cipherwake-statusbar`", op: "attempt (soft-fail if Marketplace listing missing)" });
5987
6056
  for (const e of planEntries) {
@@ -6012,6 +6081,48 @@ async function runSetupCommand(args) {
6012
6081
  console.log("");
6013
6082
  }
6014
6083
 
6084
+ // Resolve where to write Claude Code statusLine + hook entries.
6085
+ // Default 'project' = ./.claude/settings.json (this repo only). 'global' = ~/.claude/settings.json
6086
+ // (fires in every Claude Code session on this machine). Project-local is the right default for
6087
+ // anyone with multiple projects; global is the legacy behavior and now an opt-in for the
6088
+ // "one canonical domain across everything" use case.
6089
+ const claudeSettingsPath = settingsScope === "global"
6090
+ ? path.join(os.homedir(), ".claude", "settings.json")
6091
+ : path.join(process.cwd(), ".claude", "settings.json");
6092
+
6093
+ if (!skipStatusline) {
6094
+ const displayPath = claudeSettingsPath.replace(os.homedir(), "~");
6095
+ const scopeLabel = settingsScope === "global"
6096
+ ? `global (${displayPath} — fires in EVERY project on this machine)`
6097
+ : `project (${displayPath} — fires only when Claude Code runs in this directory)`;
6098
+ console.log(color("dim", `Settings scope: ${scopeLabel}`));
6099
+ console.log(color("dim", settingsScope === "global"
6100
+ ? ` (omit --scope or pass --scope project to install for this repo only)`
6101
+ : ` (pass --scope global to install for every project on this machine)`));
6102
+ console.log("");
6103
+
6104
+ // Detect existing GLOBAL Cipherwake install while doing a project install.
6105
+ // Warn so the user doesn't end up with two layers firing on top of each other.
6106
+ if (settingsScope === "project") {
6107
+ try {
6108
+ const globalPath = path.join(os.homedir(), ".claude", "settings.json");
6109
+ const raw = await fs.readFile(globalPath, "utf8");
6110
+ const parsed = JSON.parse(raw);
6111
+ const hasGlobalCipherwake =
6112
+ (parsed.statusLine?.command && String(parsed.statusLine.command).includes("cipherwake")) ||
6113
+ JSON.stringify(parsed.hooks || {}).includes("cipherwake");
6114
+ if (hasGlobalCipherwake) {
6115
+ console.log(color("yellow", ` ⚠ A GLOBAL Cipherwake install already exists at ~/.claude/settings.json`));
6116
+ console.log(color("dim", ` It will continue firing across every project. To remove the global install,`));
6117
+ console.log(color("dim", ` edit ~/.claude/settings.json and delete the statusLine entry and the`));
6118
+ console.log(color("dim", ` cipherwake-chat-hook / cipherwake-prompt-hook commands. Backups are taken`));
6119
+ console.log(color("dim", ` automatically before any write, so changes are reversible.`));
6120
+ console.log("");
6121
+ }
6122
+ } catch { /* no global install — fine */ }
6123
+ }
6124
+ }
6125
+
6015
6126
  const installSummary = [];
6016
6127
 
6017
6128
  // -------------------------------------------------------------------------
@@ -6176,7 +6287,8 @@ async function runSetupCommand(args) {
6176
6287
  }
6177
6288
 
6178
6289
  if (!skipStatusline) {
6179
- const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
6290
+ const settingsPath = claudeSettingsPath;
6291
+ const displayPath = settingsPath.replace(os.homedir(), "~");
6180
6292
  try {
6181
6293
  let settings = {};
6182
6294
  let existed = false;
@@ -6187,7 +6299,7 @@ async function runSetupCommand(args) {
6187
6299
  } catch { /* will create */ }
6188
6300
  if (existed && settings.statusLine && typeof settings.statusLine === "object") {
6189
6301
  // Already has a statusLine config — don't overwrite.
6190
- console.log(color("dim", ` ⊝ ~/.claude/settings.json already has a statusLine entry — leaving alone`));
6302
+ console.log(color("dim", ` ⊝ ${displayPath} already has a statusLine entry — leaving alone`));
6191
6303
  console.log(color("dim", ` To use the Cipherwake statusline instead, set: "command": "npx --package=pqcheck@latest cipherwake-statusline"`));
6192
6304
  installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "skipped-existing-config" });
6193
6305
  } else {
@@ -6196,7 +6308,7 @@ async function runSetupCommand(args) {
6196
6308
  settings.statusLine = { type: "command", command: "npx --package=pqcheck@latest cipherwake-statusline" };
6197
6309
  await fs.mkdir(path.dirname(settingsPath), { recursive: true });
6198
6310
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
6199
- console.log(color("green", ` ✓ added statusLine config → ~/.claude/settings.json`));
6311
+ console.log(color("green", ` ✓ added statusLine config → ${displayPath}`));
6200
6312
  installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
6201
6313
  }
6202
6314
  } catch (err) {
@@ -6212,7 +6324,8 @@ async function runSetupCommand(args) {
6212
6324
  // doesn't clobber other hooks per CLAUDE.md Rule 17.
6213
6325
  // -------------------------------------------------------------------------
6214
6326
  if (!skipStatusline) {
6215
- const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
6327
+ const settingsPath = claudeSettingsPath;
6328
+ const displayPath = settingsPath.replace(os.homedir(), "~");
6216
6329
  try {
6217
6330
  let settings = {};
6218
6331
  let existed = false;
@@ -6238,7 +6351,7 @@ async function runSetupCommand(args) {
6238
6351
  );
6239
6352
 
6240
6353
  if (alreadyInstalled) {
6241
- console.log(color("dim", ` ⊝ chat-hook already configured in ~/.claude/settings.json PostToolUse — skipping`));
6354
+ console.log(color("dim", ` ⊝ chat-hook already configured in ${displayPath} PostToolUse — skipping`));
6242
6355
  installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: "skipped-already-present" });
6243
6356
  } else {
6244
6357
  const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
@@ -6246,7 +6359,7 @@ async function runSetupCommand(args) {
6246
6359
  bashEntry.hooks.push({ type: "command", command: cipherwakeHookCmd });
6247
6360
  await fs.mkdir(path.dirname(settingsPath), { recursive: true });
6248
6361
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
6249
- console.log(color("green", ` ✓ added chat-hook (PostToolUse Bash) → ~/.claude/settings.json`));
6362
+ console.log(color("green", ` ✓ added chat-hook (PostToolUse Bash) → ${displayPath}`));
6250
6363
  console.log(color("dim", ` Every \`pqcheck\` run will now push a live status message into Claude Code chat`));
6251
6364
  installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
6252
6365
  }
@@ -6265,7 +6378,8 @@ async function runSetupCommand(args) {
6265
6378
  // / ship_decision=pass (no spam).
6266
6379
  // -------------------------------------------------------------------------
6267
6380
  if (!skipStatusline) {
6268
- const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
6381
+ const settingsPath = claudeSettingsPath;
6382
+ const displayPath = settingsPath.replace(os.homedir(), "~");
6269
6383
  try {
6270
6384
  let settings = {};
6271
6385
  let existed = false;
@@ -6285,7 +6399,7 @@ async function runSetupCommand(args) {
6285
6399
  );
6286
6400
 
6287
6401
  if (alreadyInstalled) {
6288
- console.log(color("dim", ` ⊝ prompt-hook already configured in ~/.claude/settings.json UserPromptSubmit — skipping`));
6402
+ console.log(color("dim", ` ⊝ prompt-hook already configured in ${displayPath} UserPromptSubmit — skipping`));
6289
6403
  installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: "skipped-already-present" });
6290
6404
  } else {
6291
6405
  const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
@@ -6293,7 +6407,7 @@ async function runSetupCommand(args) {
6293
6407
  settings.hooks.UserPromptSubmit.push({ hooks: [{ type: "command", command: cipherwakeHookCmd }] });
6294
6408
  await fs.mkdir(path.dirname(settingsPath), { recursive: true });
6295
6409
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
6296
- console.log(color("green", ` ✓ added prompt-hook (UserPromptSubmit) → ~/.claude/settings.json`));
6410
+ console.log(color("green", ` ✓ added prompt-hook (UserPromptSubmit) → ${displayPath}`));
6297
6411
  console.log(color("dim", ` Claude will see latest ship_decision in context on every prompt (when REVIEW/BLOCK)`));
6298
6412
  installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
6299
6413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.15",
3
+ "version": "0.16.17",
4
4
  "description": "Deploy gate for AI-coded web apps. `pqcheck deploy-check --ai` returns ship_decision=pass|review|block for Claude Code / Cursor / Copilot / Aider to gate deploys before they ship. Anonymous, no signup, free for first use.",
5
5
  "keywords": [
6
6
  "ai-coder",