pqcheck 0.16.33 → 0.17.0-beta.2

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 +31 -5
  2. package/bin/pqcheck.js +858 -142
  3. package/package.json +1 -1
package/bin/pqcheck.js CHANGED
@@ -81,6 +81,27 @@ function apiHeaders(extra = {}) {
81
81
  return h;
82
82
  }
83
83
 
84
+ // R96 — fetch with a hard timeout for the core API calls (/api/scan,
85
+ // /api/trust-diff, /api/preview-diff). Without it, a hung request stalled
86
+ // CI indefinitely; vendors/debug-network already had AbortController
87
+ // timeouts but the three load-bearing calls did not. 90s covers the
88
+ // worst server-side path (fresh full scan, maxDuration 60s) with margin.
89
+ const API_FETCH_TIMEOUT_MS = 90_000;
90
+ async function fetchWithTimeout(url, opts = {}, timeoutMs = API_FETCH_TIMEOUT_MS) {
91
+ const ctrl = new AbortController();
92
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
93
+ try {
94
+ return await fetch(url, { ...opts, signal: ctrl.signal });
95
+ } catch (err) {
96
+ if (err?.name === "AbortError") {
97
+ throw new Error(`request timed out after ${Math.round(timeoutMs / 1000)}s: ${url}`);
98
+ }
99
+ throw err;
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+
84
105
  // Helpful messaging when the server tells us auth/quota failed.
85
106
  async function handleAuthError(resp) {
86
107
  if (resp.status === 401) {
@@ -202,6 +223,13 @@ async function main() {
202
223
  if (args[0] === "deploy-check") {
203
224
  return runDeployCheckCommand(args.slice(1));
204
225
  }
226
+ if (args[0] === "last") {
227
+ // R96 feedback #3 — `pqcheck last [domain] [--remote]` reuses a recent
228
+ // verdict (local state file or GitHub Actions CI run) instead of
229
+ // forcing a duplicate live deploy-check. Advisory-only: exit 3 means
230
+ // "no reusable signal, run deploy-check".
231
+ return runLastCommand(args.slice(1));
232
+ }
205
233
  if (args[0] === "vendors") {
206
234
  return runVendorsCommand(args.slice(1));
207
235
  }
@@ -341,7 +369,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
341
369
  // the server side; if exceeded, the server silently downgrades to a
342
370
  // cached scan and returns that instead of erroring.
343
371
  const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
344
- const resp = await fetch(`${API_BASE}/api/scan${qs}`, {
372
+ const resp = await fetchWithTimeout(`${API_BASE}/api/scan${qs}`, {
345
373
  method: "GET",
346
374
  headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION}${CI_ACTION_SUFFIX} (scan)` }),
347
375
  });
@@ -441,13 +469,21 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
441
469
  // extension / AI banner) display the "UNREACHABLE" label when
442
470
  // unreachable=true instead of the generic "BLOCK" — more informative,
443
471
  // same protocol routing.
472
+ // R96 — the state file used to record the pre-posture drift decision
473
+ // while the AI footer + exit code used effectiveShip (worst-of with
474
+ // posture under --strict-posture), so statusline / prompt-hook could
475
+ // disagree with the actual gate. Compute once, record the same value
476
+ // everywhere.
477
+ const posture = report.posture || null;
478
+ const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
479
+
444
480
  await writeLastScanFile({
445
481
  domain,
446
482
  kind: "scan",
447
483
  score: typeof report.score === "number" ? report.score : null,
448
484
  grade: report.grade || null,
449
485
  max_severity: maxSev,
450
- ship_decision: shipDecision,
486
+ ship_decision: effectiveShip,
451
487
  unreachable: unreachable || false,
452
488
  top_issue: topFinding?.id || topFinding?.title || null,
453
489
  });
@@ -532,7 +568,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
532
568
  // posture_decision separates the absolute-state routing signal from the
533
569
  // drift-based ship_decision so a weak-but-unchanged site gets a review
534
570
  // nudge distinct from drift.
535
- const posture = report.posture || null;
536
571
  const postureFields = posture ? {
537
572
  posture_grade: posture.grade,
538
573
  posture_score: posture.score,
@@ -542,9 +577,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
542
577
  posture_findings_count: (posture.findings || []).length,
543
578
  posture_fixes_count: (posture.fixes || []).length,
544
579
  } : {};
545
- // R86.2 (2026-06-03) — see combineShipDecision: posture is advisory by
546
- // default, gated when --strict-posture is passed.
547
- const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
548
580
  // R86.5 — surface posture advisory line so D/F posture is never silently
549
581
  // blessed under the drift-only default.
550
582
  const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
@@ -600,9 +632,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
600
632
  console.log(JSON.stringify(report, null, 2));
601
633
  }
602
634
  } else if (format === "csv") {
603
- if (multi && this && !this.csvHeaderPrinted) {
604
- // Header only once, before first row
605
- }
606
635
  printCsvRow(report);
607
636
  } else if (format === "markdown") {
608
637
  printMarkdown(report, multi);
@@ -707,12 +736,20 @@ function isFlagValue(args, val) {
707
736
  const idx = args.indexOf(val);
708
737
  if (idx <= 0) return false;
709
738
  const prev = args[idx - 1];
710
- return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook" || prev === "--file" || prev === "-o" || prev === "--allowlist";
739
+ // R96 added --baseline/--fail-on: without them, `trust-diff acme.com
740
+ // --baseline last-week` misparsed "last-week" as the domain positional.
741
+ return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook" || prev === "--file" || prev === "-o" || prev === "--allowlist" || prev === "--baseline" || prev === "--fail-on" || prev === "--max-age";
711
742
  }
712
743
 
713
744
  function parseFormat(args) {
714
745
  if (args.includes("--json")) return "json"; // back-compat alias
715
746
  if (args.includes("--gh-action")) return "gh-action"; // GitHub Actions annotation format
747
+ // R96 — these were whitelisted in KNOWN_FLAGS but parsed by nothing,
748
+ // so `pqcheck acme.com --csv` was a silent no-op (the exact footgun
749
+ // class R86.7 exists to prevent). Wire them as aliases like --json.
750
+ if (args.includes("--csv")) return "csv";
751
+ if (args.includes("--markdown")) return "markdown";
752
+ if (args.includes("--sarif")) return "sarif";
716
753
  const i = args.indexOf("--format");
717
754
  if (i === -1) return "text";
718
755
  const v = (args[i + 1] || "").toLowerCase();
@@ -793,7 +830,7 @@ const KNOWN_FLAGS = new Set([
793
830
  "--file", "--watch",
794
831
  // Scan behaviour
795
832
  "--fresh", "--force", "--threshold", "--webhook", "--json", "--csv", "--markdown",
796
- "--sarif", "--gh-action", "--multi", "--lock", "--explain", "--plan", "--stdout",
833
+ "--sarif", "--gh-action", "--lock", "--explain", "--plan", "--stdout",
797
834
  // Posture gating (the audit catch)
798
835
  "--strict", "--strict-posture",
799
836
  // Format / output format
@@ -801,6 +838,7 @@ const KNOWN_FLAGS = new Set([
801
838
  // Trust-diff / deploy-check / preview-diff
802
839
  "--baseline", "--fail-on", "--fail-on-new", "--guards", "--compare-transport",
803
840
  "--write-baseline", "--preview", "--production",
841
+ "--protected-path", "--first-party-host",
804
842
  // Setup / onboard / install consent
805
843
  "--auto", "--manual", "--yes", "--no-open", "--domain", "--invoked-by",
806
844
  "--consent-phrase", "--scope",
@@ -962,6 +1000,10 @@ async function readRouteAssertionsConfig() {
962
1000
  // at the top level. Both forms are documented in the methodology page.
963
1001
  const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
964
1002
  if (!cfg) {
1003
+ // R96 — a domain-only config (written by `pqcheck setup`/`init` for
1004
+ // the deploy-check domain default) is a legitimate shape; don't
1005
+ // warn about missing assertions on it.
1006
+ if (typeof parsed?.domain === "string" && parsed.domain.trim()) return null;
965
1007
  _warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
966
1008
  return null;
967
1009
  }
@@ -1034,6 +1076,67 @@ async function readRouteAssertionsConfig() {
1034
1076
  }
1035
1077
  }
1036
1078
 
1079
+ // R96 (dogfood feedback #2) — `pqcheck setup`/`init` persist the monitored
1080
+ // domain into .cipherwake.json so `deploy-check` / `guard` can omit the
1081
+ // domain argument on subsequent runs. Same 5-level walk-up as
1082
+ // readRouteAssertionsConfig. Returns a validated hostname or null. Malformed
1083
+ // JSON returns null silently — readRouteAssertionsConfig already warns on it.
1084
+ async function readCipherwakeConfigDomain() {
1085
+ try {
1086
+ const { readFileSync, existsSync } = await import("node:fs");
1087
+ const { join, dirname } = await import("node:path");
1088
+ let dir = process.cwd();
1089
+ for (let i = 0; i < 5; i++) {
1090
+ const candidate = join(dir, ".cipherwake.json");
1091
+ if (existsSync(candidate)) {
1092
+ try {
1093
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
1094
+ if (typeof parsed?.domain !== "string" || !parsed.domain.trim()) return null;
1095
+ const d = normalizeDomain(parsed.domain.trim());
1096
+ return isValidDomain(d) ? d : null;
1097
+ } catch {
1098
+ return null;
1099
+ }
1100
+ }
1101
+ const parent = dirname(dir);
1102
+ if (parent === dir) break;
1103
+ dir = parent;
1104
+ }
1105
+ return null;
1106
+ } catch {
1107
+ return null;
1108
+ }
1109
+ }
1110
+
1111
+ // R96 — merge {"domain": <D>} into ./.cipherwake.json (cwd, where setup/init
1112
+ // run). Never clobbers a malformed or unexpected-shape file: the same file
1113
+ // carries customer route assertions, and destroying those to save a domain
1114
+ // field would be a terrible trade.
1115
+ async function writeDomainToCipherwakeConfig(domain) {
1116
+ const fs = await import("node:fs/promises");
1117
+ const path = await import("node:path");
1118
+ const cfgPath = path.join(process.cwd(), ".cipherwake.json");
1119
+ let parsed = {};
1120
+ let existed = false;
1121
+ try {
1122
+ const raw = await fs.readFile(cfgPath, "utf8");
1123
+ existed = true;
1124
+ try {
1125
+ parsed = JSON.parse(raw);
1126
+ } catch {
1127
+ return { status: "skipped-malformed", path: cfgPath };
1128
+ }
1129
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1130
+ return { status: "skipped-unexpected-shape", path: cfgPath };
1131
+ }
1132
+ } catch { /* file doesn't exist — will create */ }
1133
+ if (parsed.domain === domain) return { status: "already-set", path: cfgPath };
1134
+ const prior = typeof parsed.domain === "string" ? parsed.domain : null;
1135
+ parsed.domain = domain;
1136
+ await fs.writeFile(cfgPath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
1137
+ return { status: existed ? (prior ? "updated" : "merged") : "created", prior, path: cfgPath };
1138
+ }
1139
+
1037
1140
  // R87 — fold customer route-assertion failures into ship_decision. Any
1038
1141
  // CRITICAL failure (declared `protected` route that is now `exposed`) blocks
1039
1142
  // the deploy unconditionally — this is the catastrophic "admin became
@@ -1068,7 +1171,13 @@ function shipDecisionFromAssertions(summary) {
1068
1171
  if (!summary) return null;
1069
1172
  // R89/R88 wave 2 + R90 — fold in deploy health + secrets + cookies + headers + TLS.
1070
1173
  // Critical case auto-blocks; any non-critical failure promotes to review.
1071
- const deployBroken = summary.deployHealth && summary.deployHealth.status !== "healthy" && summary.deployHealth.status !== "unreachable";
1174
+ // R94.3 waf_blocked is advisory (customer's own WAF blocked the scanner
1175
+ // UA, deploy most likely healthy) so it must not flip the gate to block,
1176
+ // matching the ship_decision_health field's treatment.
1177
+ const deployBroken = summary.deployHealth
1178
+ && summary.deployHealth.status !== "healthy"
1179
+ && summary.deployHealth.status !== "unreachable"
1180
+ && summary.deployHealth.status !== "waf_blocked";
1072
1181
  const secretsLeaked = summary.secrets && summary.secrets.criticalCount > 0;
1073
1182
  const cookieCritical = (summary.cookieCriticalFailures || 0) > 0;
1074
1183
  const tlsCritical = summary.tlsExpiry && !summary.tlsExpiry.passed && summary.tlsExpiry.severity === "critical";
@@ -1299,7 +1408,10 @@ function formatAiFooterBlock(fields) {
1299
1408
  lines.push(`${safeK}=${safeV}`);
1300
1409
  }
1301
1410
  lines.push("END_CIPHERWAKE_AI_GUARD_RESULT");
1302
- return color("dim", lines.join("\n"));
1411
+ // R96 — never colorize the machine block. On a TTY, color("dim", ...)
1412
+ // wrapped the END marker in ANSI escapes, breaking strict line-match
1413
+ // parsers in pty-based agents. The block is a wire format, not UI.
1414
+ return lines.join("\n");
1303
1415
  }
1304
1416
 
1305
1417
  // v0.16.13 — fail-loud AI guard for fetch/quota/server errors during
@@ -1311,7 +1423,9 @@ function formatAiFooterBlock(fields) {
1311
1423
  // the agent can route on. Behaviour in non-AI text mode is unchanged.
1312
1424
  function emitAiGuardReviewAndExit(args, errorDetail) {
1313
1425
  if (parseAiMode(args)) {
1314
- const positional = args.filter((a) => !a.startsWith("-"));
1426
+ // R96 exclude flag VALUES, not just flags: `--baseline last-week
1427
+ // acme.com` used to label domain="last-week" in the error block.
1428
+ const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
1315
1429
  const domain = positional[0] || "";
1316
1430
  const baseline = parseFlag(args, "--baseline") || "last-scan";
1317
1431
  try {
@@ -1362,6 +1476,37 @@ function emitAiGuardReviewAndExit(args, errorDetail) {
1362
1476
  process.exit(errorDetail.exitCode ?? 3);
1363
1477
  }
1364
1478
 
1479
+ // Same fail-loud contract for preview-diff --ai (v0.16.13 fixed this for
1480
+ // trust-diff only; preview-diff error paths previously exited 3 with no
1481
+ // block, so agents parsing for CIPHERWAKE_AI_GUARD_RESULT saw "no signal").
1482
+ function emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, errorDetail) {
1483
+ if (parseAiMode(args)) {
1484
+ try {
1485
+ console.log("");
1486
+ console.log(color("yellow", " ⚠ REVIEW — Cipherwake preview-diff could not complete"));
1487
+ console.log(color("dim", ` ${errorDetail.message}`));
1488
+ console.log(color("dim", " Treating as REVIEW per fail-safe policy. Do NOT announce the deploy until you manually verify or rerun the check successfully."));
1489
+ console.log(formatAiFooterBlock({
1490
+ status: "review",
1491
+ kind: "preview-diff",
1492
+ preview_url: previewUrl || "",
1493
+ production_url: productionUrl || "",
1494
+ verdict: "review",
1495
+ ship_decision: "review",
1496
+ top_issue: errorDetail.code,
1497
+ top_issue_title: errorDetail.message,
1498
+ scanned_at: new Date().toISOString(),
1499
+ advisory_only: "true",
1500
+ error: errorDetail.code,
1501
+ }));
1502
+ console.log("");
1503
+ } catch {
1504
+ // even error path must not throw — fall through to exit
1505
+ }
1506
+ }
1507
+ process.exit(errorDetail.exitCode ?? 3);
1508
+ }
1509
+
1365
1510
  // v0.16.13 — opportunistic version check. Reads a small cache file on cold
1366
1511
  // start; if a newer version is in cache AND we haven't already banner'd
1367
1512
  // today, prints a banner to stderr. Separately, if cache is >24h old, fires
@@ -2198,6 +2343,7 @@ ${color("bold", "Commands:")}
2198
2343
  npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
2199
2344
  npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing) — see also: AI Coder Protocol at https://cipherwake.io/methodology/ai-coder-protocol
2200
2345
  npx pqcheck guard --domain <D> -- <cmd> NEW: wrap any deploy command. Runs deploy-check first; conditionally runs <cmd> based on ship_decision. The strongest single artifact for AI-coder workflows.
2346
+ npx pqcheck last [domain] NEW: reuse a recent deploy-check verdict instead of re-scanning (local state; --remote checks your GitHub Actions CI run; --max-age <min> freshness window, default 60)
2201
2347
  npx pqcheck protocol install NEW: install the AI Coder Protocol into your CLAUDE.md / .cursorrules (Rule 17 consent flow)
2202
2348
  npx pqcheck guards <init|list|run> EXPERIMENTAL (BETA): manage Site Guards (.cipherwake/guards.json) — runtime policies for source-maps / mixed-content / approved-hosts / protected-paths / cookie-flags / link-integrity. See: https://cipherwake.io/methodology/site-guards
2203
2349
  npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
@@ -3564,7 +3710,7 @@ async function runScanBasedDeployCheck(domain, args) {
3564
3710
 
3565
3711
  let resp;
3566
3712
  try {
3567
- resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
3713
+ resp = await fetchWithTimeout(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
3568
3714
  } catch (err) {
3569
3715
  // v0.16.17 — the v0.16.13 fail-loud AI guard fix was applied to the main
3570
3716
  // trust-diff deploy-check path but missed THIS fallback path (no-baseline
@@ -3716,9 +3862,12 @@ async function runScanBasedDeployCheck(domain, args) {
3716
3862
  max_severity: maxSev,
3717
3863
  ship_decision: shipDecision,
3718
3864
  unreachable: unreachable ? "true" : "false",
3865
+ // R96 — bare ids, matching the main scan path (which emits
3866
+ // `topFinding?.id` without a `findings.` prefix). Parsers shouldn't
3867
+ // need two formats for the same field.
3719
3868
  top_issue: unreachable
3720
- ? "findings.reachability.unreachable"
3721
- : (topFinding ? `findings.${topFinding.id || "unknown"}` : "none"),
3869
+ ? "reachability.unreachable"
3870
+ : (topFinding?.id || topFinding?.title || "none"),
3722
3871
  findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
3723
3872
  findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
3724
3873
  scanned_at: new Date().toISOString(),
@@ -3737,18 +3886,20 @@ async function runScanBasedDeployCheck(domain, args) {
3737
3886
  ship_decision: shipDecision,
3738
3887
  unreachable: unreachable || false,
3739
3888
  top_issue: unreachable
3740
- ? "findings.reachability.unreachable"
3889
+ ? "reachability.unreachable"
3741
3890
  : (topFinding?.id || topFinding?.title || null),
3742
3891
  note: unreachable
3743
3892
  ? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
3744
3893
  : "first-deploy: no baseline yet, scored on current state",
3745
3894
  });
3746
3895
 
3747
- // Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
3748
- process.exit(shipDecision === "pass" ? 0 : 1);
3896
+ // Exit code: 0=pass, 1=review, 2=block (matches the deploy-check AI
3897
+ // contract). R96 this previously exited 1 for block too, so the
3898
+ // pre-push hook (which refuses only on RC=2) would allow a blocked push.
3899
+ process.exit(shipDecision === "block" ? 2 : shipDecision === "pass" ? 0 : 1);
3749
3900
  }
3750
3901
 
3751
- async function runTrustDiffCommand(args) {
3902
+ async function runTrustDiffCommand(args, opts = {}) {
3752
3903
  // R86.7 — reject unknown flags before any other parsing. Same rationale
3753
3904
  // as the bare-scan path: typo'd safety-critical flags must fail loud,
3754
3905
  // not silently degrade.
@@ -3802,7 +3953,7 @@ async function runTrustDiffCommand(args) {
3802
3953
  let resp;
3803
3954
  try {
3804
3955
  const _routeCfg = await readRouteAssertionsConfig();
3805
- resp = await fetch(`${API_BASE}/api/trust-diff`, {
3956
+ resp = await fetchWithTimeout(`${API_BASE}/api/trust-diff`, {
3806
3957
  method: "POST",
3807
3958
  headers,
3808
3959
  body: JSON.stringify({
@@ -3854,7 +4005,10 @@ async function runTrustDiffCommand(args) {
3854
4005
  // through to /api/scan, populate the cache, and emit ship_decision based
3855
4006
  // on the scan's current absolute state. Subsequent deploy-checks will
3856
4007
  // have a baseline and produce a real drift verdict.
3857
- if (resp.status === 404 && parseAiMode(args)) {
4008
+ // R96 README documents this fallback for deploy-check unconditionally,
4009
+ // but it used to fire only with --ai; a non-AI first run errored instead.
4010
+ // Now: any deploy-check invocation falls through, plus any --ai caller.
4011
+ if (resp.status === 404 && (opts.deployCheck || parseAiMode(args))) {
3858
4012
  return await runScanBasedDeployCheck(domain, args);
3859
4013
  }
3860
4014
  if (!resp.ok) {
@@ -3879,9 +4033,9 @@ async function runTrustDiffCommand(args) {
3879
4033
  const freshStatus = typeof result.fresh_status === "string" ? result.fresh_status : "not_requested";
3880
4034
  if (fresh && freshStatus !== "applied" && freshStatus !== "not_requested") {
3881
4035
  const why = freshStatus === "rate_limited"
3882
- ? "fresh-scan per-IP cap reached (20/hr). The posture below is from the last cached scan — re-run after the cap window, or accept the cached read for now."
4036
+ ? "fresh-scan cap reached (20/hr per API key). The posture below is from the last cached scan — re-run after the cap window, or accept the cached read for now."
3883
4037
  : freshStatus === "unauthenticated"
3884
- ? "fresh-scan requires an API key (free tier inclusive). Set CIPHERWAKE_API_KEY or run via OIDC; without it, --fresh silently downgrades to cached. https://cipherwake.io/account#api-keys"
4038
+ ? "fresh-scan requires an API key (free tier inclusive). Set CIPHERWAKE_API_KEY; without it, --fresh silently downgrades to cached. Get a key at https://cipherwake.io/account#api-keys"
3885
4039
  : freshStatus === "unavailable"
3886
4040
  ? "fresh-scan path failed mid-run. The posture below is from the last cached scan — re-run to retry."
3887
4041
  : `fresh request returned status=${freshStatus}.`;
@@ -4017,7 +4171,12 @@ async function runTrustDiffCommand(args) {
4017
4171
  deploy_status: dh?.status,
4018
4172
  deploy_http_status: dh?.httpStatus,
4019
4173
  deploy_summary: dh?.summary?.slice(0, 200),
4020
- ship_decision_health: dh && dh.status !== "healthy" && dh.status !== "unreachable" ? "block" : (dh ? "pass" : undefined),
4174
+ // R94.3 waf_blocked is advisory, not blocking. The deploy is most
4175
+ // likely healthy; the customer's WAF blocked our scanner UA. Same
4176
+ // class as R90.1 (WAF false-positive on route assertions). Treat
4177
+ // unreachable + waf_blocked as "review" rather than "block" so the
4178
+ // customer isn't gated by their own bot-protection.
4179
+ ship_decision_health: dh && dh.status !== "healthy" && dh.status !== "unreachable" && dh.status !== "waf_blocked" ? "block" : (dh ? "pass" : undefined),
4021
4180
  // Secret scanner
4022
4181
  secrets_scanned: sc?.scanned,
4023
4182
  secrets_findings_total: sc?.findings?.length,
@@ -4037,6 +4196,12 @@ async function runTrustDiffCommand(args) {
4037
4196
  : undefined,
4038
4197
  } : {};
4039
4198
 
4199
+ // R96 feedback #4 — flake context: is the failing check a chronic flake /
4200
+ // previously-dismissed state or a first-ever failure? Sourced from local
4201
+ // .cipherwake/stats.json history; {} (silent) when nothing is failing or
4202
+ // there is no history for the failing check.
4203
+ const flakeFields = await buildFlakeContextFields(routeAssertions);
4204
+
4040
4205
  console.log(formatAiFooterBlock({
4041
4206
  status: effectiveShipWithAssertions,
4042
4207
  domain,
@@ -4074,6 +4239,7 @@ async function runTrustDiffCommand(args) {
4074
4239
  })
4075
4240
  : undefined,
4076
4241
  ...assertionFields,
4242
+ ...flakeFields,
4077
4243
  ...postureFields,
4078
4244
  // R92 — every CIPHERWAKE_AI_GUARD_RESULT block now carries fresh_status
4079
4245
  // so MCP servers / Aider / Cursor / Claude Code can route programmatically:
@@ -4502,7 +4668,7 @@ async function runPreviewDiffCommand(args) {
4502
4668
 
4503
4669
  let resp;
4504
4670
  try {
4505
- resp = await fetch(`${API_BASE}/api/preview-diff`, {
4671
+ resp = await fetchWithTimeout(`${API_BASE}/api/preview-diff`, {
4506
4672
  method: "POST",
4507
4673
  headers,
4508
4674
  body: JSON.stringify({
@@ -4518,18 +4684,30 @@ async function runPreviewDiffCommand(args) {
4518
4684
  });
4519
4685
  } catch (err) {
4520
4686
  console.error(color("red", `error: network failure calling /api/preview-diff: ${err.message}`));
4521
- process.exit(3);
4687
+ emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
4688
+ code: "network_failure",
4689
+ message: `network failure calling /api/preview-diff: ${err.message}`,
4690
+ exitCode: 3,
4691
+ });
4522
4692
  }
4523
4693
 
4524
4694
  if (resp.status === 401 || resp.status === 403) {
4525
4695
  await handleAuthError(resp);
4526
- process.exit(3);
4696
+ emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
4697
+ code: "auth_error",
4698
+ message: `authentication failed (${resp.status}) calling /api/preview-diff`,
4699
+ exitCode: 3,
4700
+ });
4527
4701
  }
4528
4702
  if (resp.status === 429) {
4529
4703
  const body = await safeJSON(resp);
4530
4704
  console.error(color("red", "error: Preview Diff API quota exceeded for this month"));
4531
4705
  if (body?.message) console.error(color("dim", body.message));
4532
- process.exit(3);
4706
+ emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
4707
+ code: "quota_exceeded",
4708
+ message: body?.message || "Preview Diff API quota exceeded for this month",
4709
+ exitCode: 3,
4710
+ });
4533
4711
  }
4534
4712
  if (!resp.ok) {
4535
4713
  const body = await safeJSON(resp);
@@ -4539,7 +4717,11 @@ async function runPreviewDiffCommand(args) {
4539
4717
  // / private-IP URLs surface the tunnel-options hint). Print it so the
4540
4718
  // user knows what to do next instead of just seeing the rejection.
4541
4719
  if (body?.hint) console.error(color("dim", body.hint));
4542
- process.exit(3);
4720
+ emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
4721
+ code: `server_error_${resp.status}`,
4722
+ message: body?.message || `/api/preview-diff returned ${resp.status}`,
4723
+ exitCode: 3,
4724
+ });
4543
4725
  }
4544
4726
 
4545
4727
  const result = await resp.json();
@@ -4838,6 +5020,15 @@ function trustDiffToSarif(result) {
4838
5020
  }
4839
5021
 
4840
5022
  function parseFlag(args, name) {
5023
+ // Supports both `--flag value` and `--flag=value` forms. The README
5024
+ // documents the equals form (e.g. `--trigger=deployment-status`), and
5025
+ // assertKnownFlags already validates it — so the parser must accept it
5026
+ // too or the flag silently no-ops (the R86.7 footgun class).
5027
+ for (const tok of args) {
5028
+ if (typeof tok === "string" && tok.startsWith(`${name}=`)) {
5029
+ return tok.slice(name.length + 1) || null;
5030
+ }
5031
+ }
4841
5032
  const idx = args.indexOf(name);
4842
5033
  if (idx === -1 || idx === args.length - 1) return null;
4843
5034
  return args[idx + 1];
@@ -5198,7 +5389,7 @@ function renderReleaseChecklist(domain, opts = {}) {
5198
5389
  `### How to verify`,
5199
5390
  ``,
5200
5391
  `\`\`\`bash`,
5201
- `# Trust posture vs last successful deploy (Free: 30 calls/mo)`,
5392
+ `# Trust posture vs last successful deploy (Free: 100 calls/mo)`,
5202
5393
  `npx pqcheck trust-diff ${domain} --baseline last-week --fail-on high`,
5203
5394
  ``,
5204
5395
  `# Third-party origins on the page (vendor scripts)`,
@@ -5231,8 +5422,8 @@ function renderReleaseChecklist(domain, opts = {}) {
5231
5422
  // --force Overwrite an existing workflow file without prompting
5232
5423
  // --stdout Print the workflow to stdout instead of writing files
5233
5424
  //
5234
- // Free tier: no API call made by init itself. The generated workflow runs
5235
- // against the user's CIPHERWAKE_API_KEY secret (30 free Trust Diff calls/mo).
5425
+ // Free tier: no API call made by init itself. The generated workflow meters
5426
+ // per repo via GitHub OIDC (100 free Trust Diff calls/mo, no API key needed).
5236
5427
  // =============================================================================
5237
5428
 
5238
5429
  const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
@@ -5343,11 +5534,14 @@ function extractStatsEntries(routeAssertions) {
5343
5534
  // Deploy health
5344
5535
  if (routeAssertions.deployHealth) {
5345
5536
  const dh = routeAssertions.deployHealth;
5537
+ // R94.3 — waf_blocked/unreachable are advisory, not gate failures;
5538
+ // recording them as critical would pollute the per-check flake stats.
5539
+ const advisory = dh.status === "waf_blocked" || dh.status === "unreachable";
5346
5540
  entries.push({
5347
5541
  id: "health:homepage",
5348
5542
  result: dh.status === "healthy" ? "pass" : "fail",
5349
5543
  status: dh.httpStatus,
5350
- severity: dh.status === "healthy" ? "info" : "critical",
5544
+ severity: dh.status === "healthy" ? "info" : (advisory ? "low" : "critical"),
5351
5545
  source: "health",
5352
5546
  });
5353
5547
  }
@@ -5364,6 +5558,63 @@ function extractStatsEntries(routeAssertions) {
5364
5558
  return entries;
5365
5559
  }
5366
5560
 
5561
+ // R96 feedback #4 — flake context for the AI guard block. When a check is
5562
+ // failing NOW, its local history (.cipherwake/stats.json) tells the agent
5563
+ // whether this is a first-ever failure (likely a real regression) or a
5564
+ // chronic / previously-dismissed one (likely flake or intentional state).
5565
+ // Called BEFORE recordResults() persists this run, so counts describe PRIOR
5566
+ // runs only. Silent (returns {}) when nothing is failing or the failing
5567
+ // check has no recorded history.
5568
+ async function buildFlakeContextFields(routeAssertions) {
5569
+ try {
5570
+ if (!routeAssertions) return {};
5571
+ const failing = [];
5572
+ for (const r of routeAssertions.results || []) {
5573
+ if (!r.passed) failing.push({ id: `route:${r.path}`, path: r.path });
5574
+ }
5575
+ for (const h of routeAssertions.headerResults || []) {
5576
+ if (!h.passed) failing.push({ id: `header:${h.header}` });
5577
+ }
5578
+ for (const c of routeAssertions.cookieResults || []) {
5579
+ if (!c.passed) failing.push({ id: `cookie:${c.namePattern}` });
5580
+ }
5581
+ for (const f of routeAssertions.secrets?.findings || []) {
5582
+ failing.push({ id: `secret:${f.patternId}` });
5583
+ }
5584
+ if (routeAssertions.deployHealth && routeAssertions.deployHealth.status !== "healthy") {
5585
+ failing.push({ id: "health:homepage" });
5586
+ }
5587
+ if (routeAssertions.tlsExpiry?.checked && !routeAssertions.tlsExpiry.passed) {
5588
+ failing.push({ id: "tls:expiry" });
5589
+ }
5590
+ if (failing.length === 0) return {};
5591
+ // Prefer the check behind the top critical failure so these fields
5592
+ // describe the same issue as assertion_top_failure.
5593
+ const topCriticalPath = routeAssertions.criticalFailures?.[0]?.path;
5594
+ const top = (topCriticalPath && failing.find((f) => f.path === topCriticalPath)) || failing[0];
5595
+ const { loadStats } = await import(new URL("./statsTracker.js", import.meta.url).href);
5596
+ const stats = await loadStats();
5597
+ const s = stats.checks?.[top.id];
5598
+ if (!s || !s.runs) return {};
5599
+ let hint;
5600
+ if (s.dismissedIntentional > 0) hint = "previously_dismissed";
5601
+ else if (s.failed === 0) hint = "first_failure";
5602
+ else if (s.runs >= 3 && s.failed / s.runs >= 0.5) hint = "frequently_failing";
5603
+ else hint = "recurring";
5604
+ const parts = [`failed ${s.failed} of ${s.runs} prior runs`];
5605
+ if (s.dismissedIntentional > 0) parts.push(`dismissed as intentional ${s.dismissedIntentional}x`);
5606
+ if (s.confirmedReal > 0) parts.push(`confirmed real ${s.confirmedReal}x`);
5607
+ return {
5608
+ top_failure_id: top.id,
5609
+ top_failure_history: parts.join("; "),
5610
+ flake_hint: hint,
5611
+ };
5612
+ } catch {
5613
+ // never block the gate on stats issues
5614
+ return {};
5615
+ }
5616
+ }
5617
+
5367
5618
  async function runInitCommand(args) {
5368
5619
  const fs = await import("node:fs/promises");
5369
5620
  const path = await import("node:path");
@@ -5374,6 +5625,20 @@ async function runInitCommand(args) {
5374
5625
  const flagDomain = readFlagValue(args, "--domain");
5375
5626
  const flagFailOn = readFlagValue(args, "--fail-on");
5376
5627
  const flagBaseline = readFlagValue(args, "--baseline");
5628
+ // R94.3 (2026-06-08) — stable-track default flipped to opt-in.
5629
+ // `push:main` is back-compat-safe for every platform (including
5630
+ // custom CD scripts + manual rollouts). `deployment-status` is the
5631
+ // smart Vercel/Netlify trigger (recommended, R93) but only fires
5632
+ // when a real `deployment_status` event arrives, which means
5633
+ // someone on a non-deployment-event platform gets ZERO runs.
5634
+ // Default is now `push` so initial install never silently does
5635
+ // nothing; explicit `--trigger=deployment-status` for Vercel/Netlify.
5636
+ const flagTrigger = readFlagValue(args, "--trigger") || "push";
5637
+ if (!["push", "deployment-status", "deployment_status"].includes(flagTrigger)) {
5638
+ console.error(color("red", `error: --trigger must be 'push' (default) or 'deployment-status'`));
5639
+ process.exit(1);
5640
+ }
5641
+ const triggerMode = flagTrigger === "deployment_status" ? "deployment-status" : flagTrigger;
5377
5642
 
5378
5643
  console.log("");
5379
5644
  console.log(` ${color("bold", "pqcheck init")} ${color("dim", "— scaffold a Cipherwake GitHub Action workflow")}`);
@@ -5413,7 +5678,7 @@ async function runInitCommand(args) {
5413
5678
  process.exit(1);
5414
5679
  }
5415
5680
 
5416
- const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline });
5681
+ const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode });
5417
5682
 
5418
5683
  if (stdout) {
5419
5684
  console.log(workflow);
@@ -5461,27 +5726,47 @@ async function runInitCommand(args) {
5461
5726
  const relPath = path.relative(cwd, workflowPath);
5462
5727
  console.log("");
5463
5728
  console.log(color("green", ` ✓ Wrote ${relPath}`));
5729
+
5730
+ // R96 (dogfood feedback #2) — persist the domain so deploy-check/guard
5731
+ // can default to it on subsequent runs without a domain argument.
5732
+ try {
5733
+ const cfgResult = await writeDomainToCipherwakeConfig(domain);
5734
+ if (cfgResult.status === "created") {
5735
+ console.log(color("green", ` ✓ Wrote .cipherwake.json (domain: ${domain} — deploy-check/guard can now omit the domain argument)`));
5736
+ } else if (cfgResult.status === "merged") {
5737
+ console.log(color("green", ` ✓ Added "domain": "${domain}" to .cipherwake.json`));
5738
+ } else if (cfgResult.status === "updated") {
5739
+ console.log(color("green", ` ✓ Updated .cipherwake.json domain: ${cfgResult.prior} → ${domain}`));
5740
+ } else if (cfgResult.status === "skipped-malformed" || cfgResult.status === "skipped-unexpected-shape") {
5741
+ console.log(color("yellow", ` ⚠ .cipherwake.json could not be updated (${cfgResult.status === "skipped-malformed" ? "malformed JSON" : "not a JSON object"}) — add "domain": "${domain}" by hand`));
5742
+ }
5743
+ } catch (err) {
5744
+ console.log(color("yellow", ` ⚠ .cipherwake.json domain write failed: ${err.message} (non-fatal)`));
5745
+ }
5464
5746
  console.log("");
5465
5747
  console.log(` ${color("bold", "Next steps:")}`);
5466
5748
  console.log("");
5467
- console.log(` ${color("dim", "1.")} Generate a Cipherwake API key at ${color("violet", "https://cipherwake.io/account#api-keys")}`);
5468
- console.log(` ${color("dim", "Free tier: 100 Trust Diff calls/month per repo")}`);
5469
- console.log("");
5470
- console.log(` ${color("dim", "2.")} Add it as a repo secret:`);
5471
- console.log(` ${color("dim", "Settings → Secrets and variables → Actions → New repository secret")}`);
5472
- console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY")}`);
5473
- console.log("");
5474
- console.log(` ${color("dim", "3.")} Commit + push:`);
5749
+ console.log(` ${color("dim", "1.")} Commit + push — no API key or repo secret needed:`);
5750
+ console.log(` ${color("dim", "The workflow meters per repo via GitHub OIDC (Free: 100 Trust Diff calls/month).")}`);
5475
5751
  console.log(` ${color("dim", "$")} git add ${relPath}`);
5476
5752
  console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
5477
5753
  console.log(` ${color("dim", "$")} git push`);
5478
5754
  console.log("");
5755
+ console.log(` ${color("dim", "2.")} ${color("dim", "Optional — higher limits:")} add a key from ${color("violet", "https://cipherwake.io/account#api-keys")} as the CIPHERWAKE_API_KEY repo secret.`);
5756
+ console.log("");
5479
5757
  console.log(` Open a PR to see the gate run.`);
5480
5758
  console.log("");
5481
5759
  process.exit(0);
5482
5760
  }
5483
5761
 
5484
5762
  function readFlagValue(args, name) {
5763
+ // `--flag=value` form first (documented syntax for `init --trigger=...`),
5764
+ // then the space-separated `--flag value` form.
5765
+ for (const tok of args) {
5766
+ if (typeof tok === "string" && tok.startsWith(`${name}=`)) {
5767
+ return tok.slice(name.length + 1) || null;
5768
+ }
5769
+ }
5485
5770
  const idx = args.indexOf(name);
5486
5771
  if (idx === -1) return null;
5487
5772
  const v = args[idx + 1];
@@ -5494,23 +5779,59 @@ function isValidBaseline(value) {
5494
5779
  return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?)?$/.test(value);
5495
5780
  }
5496
5781
 
5497
- function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
5782
+ function renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode = "push" }) {
5783
+ const isDeploymentStatus = triggerMode === "deployment-status";
5784
+
5785
+ // The trigger YAML block + job-level `if:` guard differ between modes.
5786
+ // push (default, back-compat): fires on every push to main. Safe for any
5787
+ // platform but can race git-integrated deploys (Vercel/Netlify).
5788
+ // deployment-status (--trigger=deployment-status): fires AFTER a successful
5789
+ // Production deploy. Race-safe for Vercel/Netlify/Render/Railway. Does
5790
+ // nothing on platforms that don't emit the event (custom CD scripts).
5791
+ const triggerBlock = isDeploymentStatus
5792
+ ? `on:
5793
+ pull_request:
5794
+ branches: [main]
5795
+ deployment_status:`
5796
+ : `on:
5797
+ pull_request:
5798
+ branches: [main]
5799
+ push:
5800
+ branches: [main]`;
5801
+
5802
+ const jobGuard = isDeploymentStatus
5803
+ ? ` # Run on PRs OR on successful Production deployments.
5804
+ if: |
5805
+ github.event_name == 'pull_request' ||
5806
+ (github.event_name == 'deployment_status' &&
5807
+ github.event.deployment_status.state == 'success' &&
5808
+ (github.event.deployment.environment == 'production' ||
5809
+ github.event.deployment.environment == 'Production'))`
5810
+ : ` # Runs on PRs (advisory) and pushes to main (gate).`;
5811
+
5812
+ const triggerNote = isDeploymentStatus
5813
+ ? `# Trigger: deployment_status (--trigger=deployment-status). The job fires
5814
+ # AFTER a successful Production deployment, so trust-diff sees the surface
5815
+ # that actually shipped — not the stale prior production. Recommended for
5816
+ # Vercel / Netlify / Render / Railway and other git-integrated platforms.
5817
+ # Switch to 'push' if your platform does NOT emit deployment_status events
5818
+ # (custom CD scripts, S3-sync deploys, manual rollouts).`
5819
+ : `# Trigger: push to main (default). Back-compat-safe on every platform.
5820
+ # If you're on Vercel / Netlify / Render / Railway, switch to
5821
+ # \`--trigger=deployment-status\` — the post-deploy gate is more accurate
5822
+ # because the push:main trigger can race git-integrated deploys, diffing
5823
+ # the previous production surface before the new one is live.`;
5824
+
5498
5825
  return `# Cipherwake — Trust Diff gate
5499
5826
  # Generated by \`pqcheck init\` (v${VERSION}).
5500
5827
  # Runs:
5501
5828
  # - on every PR (advisory diff against the production baseline)
5502
- # - on every successful Production deployment (post-deploy gate)
5829
+ # - on every push to main / successful Production deployment (gate)
5503
5830
  # Fails the build if your public trust posture regresses vs the baseline
5504
5831
  # (cert / SPKI / vendor scripts / HSTS / CSP / DMARC / HNDL / declared
5505
5832
  # route assertions).
5506
5833
  #
5507
- # R93 (2026-06-08): defaults to deployment_status (success + Production) for
5508
- # the post-deploy job, NOT push:main. Reason: on platforms with git-integrated
5509
- # deploys (Vercel, Netlify, Render, Railway, etc.) the deploy fires from the
5510
- # same push event — running on push:main would RACE the deploy and diff the
5511
- # STALE production surface before the new deploy is live. deployment_status
5512
- # fires AFTER the deploy lands, so trust-diff sees the surface that actually
5513
- # shipped.
5834
+ ${triggerNote}
5514
5835
  #
5515
5836
  # Free tier: 100 Trust Diff calls/month per repo (OIDC-metered).
5516
5837
  # Methodology: https://cipherwake.io/methodology/
@@ -5518,10 +5839,7 @@ function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
5518
5839
 
5519
5840
  name: Cipherwake Trust Diff
5520
5841
 
5521
- on:
5522
- pull_request:
5523
- branches: [main]
5524
- deployment_status:
5842
+ ${triggerBlock}
5525
5843
 
5526
5844
  permissions:
5527
5845
  contents: read
@@ -5532,18 +5850,7 @@ permissions:
5532
5850
  jobs:
5533
5851
  trust-diff:
5534
5852
  runs-on: ubuntu-latest
5535
- # Run on PRs OR on successful Production deployments.
5536
- # PRs: github.event_name == "pull_request" — advisory diff for the change
5537
- # Deployment: deployment_status.state == "success" — only after a deploy
5538
- # actually succeeded; skips failed/error/pending events. Environment
5539
- # filter on "production" / "Production" so preview deploys don't
5540
- # trigger trust-diff against the prod domain (different surfaces).
5541
- if: |
5542
- github.event_name == 'pull_request' ||
5543
- (github.event_name == 'deployment_status' &&
5544
- github.event.deployment_status.state == 'success' &&
5545
- (github.event.deployment.environment == 'production' ||
5546
- github.event.deployment.environment == 'Production'))
5853
+ ${jobGuard}
5547
5854
  steps:
5548
5855
  - name: Run Cipherwake Trust Diff
5549
5856
  uses: cipherwakelabs/pqcheck@v4
@@ -5557,16 +5864,6 @@ jobs:
5557
5864
  # OIDC token and meters per repo (100 calls/mo, no setup).
5558
5865
  # If you want higher limits, link this repo to a paid Cipherwake
5559
5866
  # account at https://cipherwake.io/account → Linked repos.
5560
- #
5561
- # If your platform does NOT emit deployment_status events (custom
5562
- # CD scripts, S3-sync deploys, manual rollouts), replace the
5563
- # deployment_status trigger above with:
5564
- #
5565
- # push:
5566
- # branches: [main]
5567
- #
5568
- # AND add a delay/health-check step before this one so trust-diff
5569
- # runs AFTER your deploy is live, not racing it.
5570
5867
  `;
5571
5868
  }
5572
5869
 
@@ -5599,14 +5896,25 @@ async function prompt(question) {
5599
5896
 
5600
5897
  async function runDeployCheckCommand(args) {
5601
5898
  const positional = args.filter((a) => !a.startsWith("-"));
5899
+ let forwarded = [...args];
5602
5900
  if (positional.length === 0) {
5603
- console.error(color("red", "error: pqcheck deploy-check requires a domain"));
5604
- console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
5605
- process.exit(1);
5901
+ // R96 (dogfood feedback #2) — no domain argument: fall back to the
5902
+ // `domain` field in .cipherwake.json (written by `pqcheck setup`/`init`)
5903
+ // so AI coders can run `npx pqcheck deploy-check --ai` without guessing
5904
+ // which domain this repo deploys to.
5905
+ const cfgDomain = await readCipherwakeConfigDomain();
5906
+ if (cfgDomain) {
5907
+ console.error(color("dim", ` ℹ no domain argument — using "${cfgDomain}" from .cipherwake.json`));
5908
+ forwarded = [cfgDomain, ...forwarded];
5909
+ } else {
5910
+ console.error(color("red", "error: pqcheck deploy-check requires a domain"));
5911
+ console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
5912
+ console.error(color("dim", `Tip: run \`npx pqcheck setup --auto --domain <domain>\` once and the domain is saved to .cipherwake.json — after that, plain \`npx pqcheck deploy-check --ai\` works.`));
5913
+ process.exit(1);
5914
+ }
5606
5915
  }
5607
5916
 
5608
5917
  // Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
5609
- const forwarded = [...args];
5610
5918
  if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
5611
5919
  if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
5612
5920
 
@@ -5620,7 +5928,296 @@ async function runDeployCheckCommand(args) {
5620
5928
  console.log("");
5621
5929
  }
5622
5930
 
5623
- return runTrustDiffCommand(forwarded);
5931
+ return runTrustDiffCommand(forwarded, { deployCheck: true });
5932
+ }
5933
+
5934
+ // =============================================================================
5935
+ // `pqcheck last` — reuse the most recent gate result instead of re-scanning
5936
+ // =============================================================================
5937
+ // R96 (dogfood feedback #3). After CI already ran the deploy gate, an AI
5938
+ // coder following the protocol shouldn't burn a duplicate Trust Diff quota
5939
+ // call just to learn "the gate already passed." Two sources:
5940
+ //
5941
+ // pqcheck last [domain] — local state files written by every scan /
5942
+ // deploy-check: .cipherwake/last-status.json
5943
+ // (repo-local) else ~/.config/cipherwake/last-scan.json
5944
+ // pqcheck last --remote — the latest GitHub Actions run of the
5945
+ // cipherwake.yml workflow for this repo (the CI
5946
+ // gate installed by `pqcheck init`/`setup`).
5947
+ // Public GitHub API; GITHUB_TOKEN/GH_TOKEN used
5948
+ // when set. ZERO Trust Diff quota consumed.
5949
+ //
5950
+ // Honesty guards — a cached result can only bless an announce when it is
5951
+ // genuinely equivalent to running the check now:
5952
+ // • older than --max-age (default 60 min) → NOT reusable
5953
+ // • CI run for a different commit than local HEAD → NOT reusable
5954
+ // • CI still running / cancelled → NOT reusable
5955
+ // A failed CI run IS surfaced as review (conservative direction is safe).
5956
+ //
5957
+ // Exit codes (agent routing contract):
5958
+ // 0 = reusable result, gate passed — safe to skip the duplicate deploy-check
5959
+ // 1 = last result was review · 2 = block (fresh enough to trust as signal)
5960
+ // 3 = no reusable signal — run `npx pqcheck deploy-check --ai` instead
5961
+ // =============================================================================
5962
+
5963
+ async function runLastCommand(args) {
5964
+ const aiMode = parseAiMode(args);
5965
+ const remote = args.includes("--remote");
5966
+ const maxAgeRaw = parseFlag(args, "--max-age");
5967
+ const maxAgeMin = maxAgeRaw === null || maxAgeRaw === undefined ? 60 : Number(maxAgeRaw);
5968
+ if (!Number.isFinite(maxAgeMin) || maxAgeMin <= 0) {
5969
+ console.error(color("red", "error: --max-age requires a positive number of minutes"));
5970
+ process.exit(3);
5971
+ }
5972
+ const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
5973
+ const domainFilter = positional[0] ? normalizeDomain(positional[0]) : await readCipherwakeConfigDomain();
5974
+
5975
+ // Shared emitter: human lines + optional AI block + routing exit code.
5976
+ // exitCode 3 = "no reusable signal" — the block still says ship_decision=
5977
+ // review (fail-safe: agent must run the real check, never assume pass).
5978
+ const finish = ({ shipDecision, exitCode, fields, humanLines }) => {
5979
+ console.log("");
5980
+ for (const l of humanLines) console.log(l);
5981
+ if (aiMode) {
5982
+ console.log(formatAiFooterBlock({
5983
+ status: shipDecision,
5984
+ kind: "last",
5985
+ ...fields,
5986
+ ship_decision: shipDecision,
5987
+ reusable: exitCode === 3 ? "false" : "true",
5988
+ max_age_minutes: maxAgeMin,
5989
+ advisory_only: "true",
5990
+ note: exitCode === 3
5991
+ ? "No reusable result. Run: npx pqcheck deploy-check --ai"
5992
+ : "Cached gate result. Exit 0 = CI/last check passed and covers the current state; re-run deploy-check after any new deploy.",
5993
+ checked_at: new Date().toISOString(),
5994
+ }));
5995
+ }
5996
+ console.log("");
5997
+ process.exit(exitCode);
5998
+ };
5999
+
6000
+ // ---------------------------------------------------------------------------
6001
+ // --remote: latest cipherwake.yml GitHub Actions run for this repo
6002
+ // ---------------------------------------------------------------------------
6003
+ if (remote) {
6004
+ const { execFile } = await import("node:child_process");
6005
+ const git = (gitArgs) => new Promise((resolve) => {
6006
+ execFile("git", gitArgs, { timeout: 5000 }, (err, stdout) => resolve(err ? null : String(stdout).trim()));
6007
+ });
6008
+ const originUrl = await git(["remote", "get-url", "origin"]);
6009
+ if (!originUrl) {
6010
+ return finish({
6011
+ shipDecision: "review", exitCode: 3,
6012
+ fields: { source: "github_actions", top_issue: "no_git_origin" },
6013
+ humanLines: [color("red", " ✗ pqcheck last --remote needs a git repo with an `origin` remote"), color("dim", " Run inside the repo whose CI gate you want to read.")],
6014
+ });
6015
+ }
6016
+ const m = originUrl.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
6017
+ if (!m) {
6018
+ return finish({
6019
+ shipDecision: "review", exitCode: 3,
6020
+ fields: { source: "github_actions", top_issue: "origin_not_github" },
6021
+ humanLines: [color("red", ` ✗ --remote currently reads GitHub Actions only (origin: ${originUrl})`)],
6022
+ });
6023
+ }
6024
+ const owner = m[1];
6025
+ const repo = m[2];
6026
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
6027
+ const localSha = await git(["rev-parse", "HEAD"]);
6028
+
6029
+ const branchParam = branch && branch !== "HEAD" ? `&branch=${encodeURIComponent(branch)}` : "";
6030
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/cipherwake.yml/runs?per_page=1${branchParam}`;
6031
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
6032
+ let resp;
6033
+ try {
6034
+ const ctrl = new AbortController();
6035
+ const tmr = setTimeout(() => ctrl.abort(), 10000);
6036
+ resp = await fetch(apiUrl, {
6037
+ headers: {
6038
+ accept: "application/vnd.github+json",
6039
+ "user-agent": `pqcheck/${VERSION}`,
6040
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
6041
+ },
6042
+ signal: ctrl.signal,
6043
+ });
6044
+ clearTimeout(tmr);
6045
+ } catch (err) {
6046
+ return finish({
6047
+ shipDecision: "review", exitCode: 3,
6048
+ fields: { source: "github_actions", top_issue: "github_api_unreachable" },
6049
+ humanLines: [color("red", ` ✗ GitHub API unreachable: ${err?.message || err}`)],
6050
+ });
6051
+ }
6052
+ if (resp.status === 404) {
6053
+ return finish({
6054
+ shipDecision: "review", exitCode: 3,
6055
+ fields: { source: "github_actions", top_issue: "no_cipherwake_workflow", repo: `${owner}/${repo}` },
6056
+ humanLines: [
6057
+ color("yellow", ` ⊝ ${owner}/${repo} has no cipherwake.yml workflow on GitHub`),
6058
+ color("dim", " Install the CI gate: npx pqcheck init --domain <domain> (then commit + push)"),
6059
+ ],
6060
+ });
6061
+ }
6062
+ if (!resp.ok) {
6063
+ return finish({
6064
+ shipDecision: "review", exitCode: 3,
6065
+ fields: { source: "github_actions", top_issue: `github_api_http_${resp.status}` },
6066
+ humanLines: [
6067
+ color("red", ` ✗ GitHub API returned HTTP ${resp.status}`),
6068
+ ...(resp.status === 403 && !token ? [color("dim", " Likely the anonymous rate limit (60/hr per IP) or a private repo — set GITHUB_TOKEN and retry.")] : []),
6069
+ ],
6070
+ });
6071
+ }
6072
+ const data = await resp.json().catch(() => null);
6073
+ const run = data?.workflow_runs?.[0];
6074
+ if (!run) {
6075
+ return finish({
6076
+ shipDecision: "review", exitCode: 3,
6077
+ fields: { source: "github_actions", top_issue: "no_workflow_runs", repo: `${owner}/${repo}` },
6078
+ humanLines: [color("yellow", ` ⊝ cipherwake.yml exists but has no runs${branchParam ? ` on branch ${branch}` : ""} yet — push to trigger one`)],
6079
+ });
6080
+ }
6081
+
6082
+ const ranAt = Date.parse(run.updated_at || run.run_started_at || "");
6083
+ const ageMin = Number.isFinite(ranAt) ? Math.round((Date.now() - ranAt) / 60000) : null;
6084
+ const stale = ageMin === null || ageMin > maxAgeMin;
6085
+ const shaMatch = !!(localSha && run.head_sha && run.head_sha === localSha);
6086
+ const shortSha = String(run.head_sha || "").slice(0, 7);
6087
+ const baseFields = {
6088
+ source: "github_actions",
6089
+ ...(domainFilter ? { domain: domainFilter } : {}),
6090
+ repo: `${owner}/${repo}`,
6091
+ ci_status: run.status,
6092
+ ci_conclusion: run.conclusion || "",
6093
+ head_sha: run.head_sha || "",
6094
+ head_sha_match: shaMatch ? "true" : "false",
6095
+ age_minutes: ageMin ?? "unknown",
6096
+ stale: stale ? "true" : "false",
6097
+ run_url: run.html_url || "",
6098
+ };
6099
+ const headerLines = [
6100
+ ` ${color("bold", "◆ Cipherwake — last CI gate result")} ${color("dim", `(${owner}/${repo} · cipherwake.yml)`)}`,
6101
+ ` ${color("dim", `Run: ${run.status}${run.conclusion ? ` / ${run.conclusion}` : ""} · commit ${shortSha}${shaMatch ? " (matches local HEAD)" : localSha ? ` (local HEAD is ${localSha.slice(0, 7)} — DIFFERENT)` : ""} · ${ageMin ?? "?"}m ago`)}`,
6102
+ ` ${color("dim", `URL: ${run.html_url || "?"}`)}`,
6103
+ ];
6104
+
6105
+ if (run.status !== "completed") {
6106
+ return finish({
6107
+ shipDecision: "review", exitCode: 3,
6108
+ fields: { ...baseFields, top_issue: "ci_run_in_progress" },
6109
+ humanLines: [...headerLines, color("yellow", " ⚠ CI run still in progress — wait for it, or run deploy-check directly")],
6110
+ });
6111
+ }
6112
+ if (run.conclusion === "failure") {
6113
+ // Conservative direction: a failed gate is a trustworthy "do not
6114
+ // announce" signal even when stale or for a different commit.
6115
+ return finish({
6116
+ shipDecision: "review", exitCode: 1,
6117
+ fields: { ...baseFields, top_issue: "ci_gate_failed" },
6118
+ humanLines: [...headerLines, color("yellow", " ⚠ REVIEW — the CI deploy gate FAILED on its last run. Inspect the run before announcing anything.")],
6119
+ });
6120
+ }
6121
+ if (run.conclusion !== "success") {
6122
+ return finish({
6123
+ shipDecision: "review", exitCode: 3,
6124
+ fields: { ...baseFields, top_issue: `ci_run_${run.conclusion || "unknown"}` },
6125
+ humanLines: [...headerLines, color("yellow", ` ⊝ CI run ended "${run.conclusion}" — no reusable verdict`)],
6126
+ });
6127
+ }
6128
+ if (stale) {
6129
+ return finish({
6130
+ shipDecision: "review", exitCode: 3,
6131
+ fields: { ...baseFields, top_issue: "ci_result_stale" },
6132
+ humanLines: [...headerLines, color("yellow", ` ⊝ CI gate passed but ${ageMin ?? "?"}m ago exceeds --max-age ${maxAgeMin}m — run a fresh deploy-check`)],
6133
+ });
6134
+ }
6135
+ if (!shaMatch) {
6136
+ return finish({
6137
+ shipDecision: "review", exitCode: 3,
6138
+ fields: { ...baseFields, top_issue: "ci_result_for_different_commit" },
6139
+ humanLines: [...headerLines, color("yellow", " ⊝ CI gate passed, but for a different commit than your local HEAD — its verdict doesn't cover your changes")],
6140
+ });
6141
+ }
6142
+ return finish({
6143
+ shipDecision: "pass", exitCode: 0,
6144
+ fields: { ...baseFields, top_issue: "none" },
6145
+ humanLines: [...headerLines, color("green", " ✓ PASS — CI deploy gate passed on this exact commit within the freshness window. Safe to reuse; no duplicate deploy-check needed.")],
6146
+ });
6147
+ }
6148
+
6149
+ // ---------------------------------------------------------------------------
6150
+ // Local: state files written by every scan / deploy-check
6151
+ // ---------------------------------------------------------------------------
6152
+ const fs = await import("node:fs/promises");
6153
+ const path = await import("node:path");
6154
+ const os = await import("node:os");
6155
+ const candidates = [
6156
+ { source: "repo_state", file: path.join(process.cwd(), ".cipherwake", "last-status.json") },
6157
+ { source: "user_state", file: path.join(os.homedir(), ".config", "cipherwake", "last-scan.json") },
6158
+ ];
6159
+ let state = null;
6160
+ let source = null;
6161
+ let stateFile = null;
6162
+ for (const c of candidates) {
6163
+ try {
6164
+ const parsed = JSON.parse(await fs.readFile(c.file, "utf8"));
6165
+ if (domainFilter && normalizeDomain(String(parsed?.domain || "")) !== domainFilter) continue;
6166
+ state = parsed;
6167
+ source = c.source;
6168
+ stateFile = c.file;
6169
+ break;
6170
+ } catch { /* missing or malformed — try next source */ }
6171
+ }
6172
+
6173
+ if (!state) {
6174
+ return finish({
6175
+ shipDecision: "review", exitCode: 3,
6176
+ fields: { source: "local_state", ...(domainFilter ? { domain: domainFilter } : {}), top_issue: "no_recent_result" },
6177
+ humanLines: [
6178
+ ` ${color("bold", "◆ Cipherwake — last result")}`,
6179
+ color("yellow", ` ⊝ no local result found${domainFilter ? ` for ${domainFilter}` : ""}`),
6180
+ color("dim", " Run: npx pqcheck deploy-check --ai (or `pqcheck last --remote` to read the CI gate)"),
6181
+ ],
6182
+ });
6183
+ }
6184
+
6185
+ const writtenAt = Date.parse(state.written_at || "");
6186
+ const ageMin = Number.isFinite(writtenAt) ? Math.round((Date.now() - writtenAt) / 60000) : null;
6187
+ const stale = ageMin === null || ageMin > maxAgeMin;
6188
+ const storedShip = ["pass", "review", "block"].includes(state.ship_decision) ? state.ship_decision : "review";
6189
+ const fields = {
6190
+ source,
6191
+ domain: state.domain || domainFilter || "",
6192
+ last_kind: state.kind || "",
6193
+ last_ship_decision: storedShip,
6194
+ ...(typeof state.score === "number" ? { dbr: state.score.toFixed(1) } : {}),
6195
+ ...(state.grade ? { grade: state.grade } : {}),
6196
+ ...(state.top_issue ? { last_top_issue: state.top_issue } : {}),
6197
+ age_minutes: ageMin ?? "unknown",
6198
+ stale: stale ? "true" : "false",
6199
+ state_file: stateFile,
6200
+ };
6201
+ const headerLines = [
6202
+ ` ${color("bold", "◆ Cipherwake — last result")} ${color("dim", `(${source === "repo_state" ? ".cipherwake/last-status.json" : "~/.config/cipherwake/last-scan.json"})`)}`,
6203
+ ` ${color("dim", `Domain: ${state.domain || "?"} · kind: ${state.kind || "?"} · ship_decision: ${storedShip} · ${ageMin ?? "?"}m ago`)}`,
6204
+ ];
6205
+ if (stale) {
6206
+ return finish({
6207
+ shipDecision: "review", exitCode: 3,
6208
+ fields: { ...fields, top_issue: "stale_result" },
6209
+ humanLines: [...headerLines, color("yellow", ` ⊝ result is ${ageMin ?? "?"}m old — exceeds --max-age ${maxAgeMin}m. Run a fresh deploy-check.`)],
6210
+ });
6211
+ }
6212
+ const marker = storedShip === "pass" ? color("green", " ✓ PASS — last check passed within the freshness window.")
6213
+ : storedShip === "review" ? color("yellow", " ⚠ REVIEW — last check flagged a change. Surface it to the user before announcing.")
6214
+ : color("red", " ✗ BLOCK — last check returned block. Do not announce.");
6215
+ return finish({
6216
+ shipDecision: storedShip,
6217
+ exitCode: shipDecisionExitCode(storedShip),
6218
+ fields: { ...fields, top_issue: state.top_issue || "none" },
6219
+ humanLines: [...headerLines, marker],
6220
+ });
5624
6221
  }
5625
6222
 
5626
6223
  // =============================================================================
@@ -6158,7 +6755,7 @@ async function runOnboardCommand(args) {
6158
6755
  console.log(` ${color("dim", "$")} git push`);
6159
6756
  console.log("");
6160
6757
  console.log(` ${color("dim", "2.")} ${color("bold", "Open a PR")}`);
6161
- console.log(` ${color("dim", "Cipherwake will comment inline within ~60s of the workflow firing. The action uses GitHub OIDC to meter usage per repo (Free = 30 calls/mo).")}`);
6758
+ console.log(` ${color("dim", "Cipherwake will comment inline within ~60s of the workflow firing. The action uses GitHub OIDC to meter usage per repo (Free = 100 calls/mo).")}`);
6162
6759
  console.log("");
6163
6760
  // R48 (post-R47 review MAJOR #6): the /account → "Linked repos" UI is
6164
6761
  // not yet shipped (out of R47 scope). Pointing users to a nonexistent
@@ -6190,48 +6787,6 @@ function buildReleaseChecklistMarkdown(domain) {
6190
6787
  return renderReleaseChecklist(domain, { generator: "onboard" });
6191
6788
  }
6192
6789
 
6193
- // Cross-platform browser launcher. Returns true if a launcher binary
6194
- // dispatched successfully; false if no launcher is available (e.g. headless
6195
- // server, sandboxed CI, broken xdg-open config).
6196
- //
6197
- // R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
6198
- // so we don't falsely claim "(opened in your browser)" when xdg-open is
6199
- // installed but the launcher exits non-zero (no graphical session, no
6200
- // MIME handler). Previously a flat 200ms timeout resolved true even when
6201
- // the launcher exited 3 because no display was available.
6202
- async function tryOpenBrowser(url) {
6203
- if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
6204
- const { spawn } = await import("node:child_process");
6205
- const platform = process.platform;
6206
- let cmd, cmdArgs;
6207
- if (platform === "darwin") {
6208
- cmd = "open"; cmdArgs = [url];
6209
- } else if (platform === "win32") {
6210
- cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
6211
- } else {
6212
- cmd = "xdg-open"; cmdArgs = [url];
6213
- }
6214
- return await new Promise((resolve) => {
6215
- let settled = false;
6216
- let p;
6217
- try {
6218
- p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
6219
- } catch {
6220
- resolve(false);
6221
- return;
6222
- }
6223
- p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
6224
- p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
6225
- p.unref();
6226
- // Belt-and-suspenders: if the launcher takes >1s to exit AND no error
6227
- // event has fired, assume it dispatched and went detached (open on
6228
- // macOS does this — returns after AppleScript-asking Finder/Safari).
6229
- setTimeout(() => {
6230
- if (!settled) { settled = true; resolve(true); }
6231
- }, 1000);
6232
- });
6233
- }
6234
-
6235
6790
  // =============================================================================
6236
6791
  // `pqcheck guard --domain X -- <deploy command>` — wrapper command
6237
6792
  // =============================================================================
@@ -6257,15 +6812,24 @@ async function runGuardCommand(args) {
6257
6812
  const ourArgs = sepIdx >= 0 ? args.slice(0, sepIdx) : args;
6258
6813
  const deployCmd = sepIdx >= 0 ? args.slice(sepIdx + 1) : [];
6259
6814
 
6260
- const domain = parseFlag(ourArgs, "--domain");
6815
+ let domain = parseFlag(ourArgs, "--domain");
6261
6816
  const gateMode = parseFlag(ourArgs, "--gate-mode") || "balanced";
6262
6817
  const bypassReason = parseFlag(ourArgs, "--bypass");
6263
6818
  const noPostCheck = ourArgs.includes("--no-post-check");
6264
6819
 
6820
+ if (!domain) {
6821
+ // R96 (dogfood feedback #2) — fall back to the `domain` field in
6822
+ // .cipherwake.json (written by `pqcheck setup`/`init`).
6823
+ domain = await readCipherwakeConfigDomain();
6824
+ if (domain) {
6825
+ console.error(color("dim", ` ℹ no --domain flag — using "${domain}" from .cipherwake.json`));
6826
+ }
6827
+ }
6265
6828
  if (!domain) {
6266
6829
  console.error(color("red", "error: pqcheck guard requires --domain"));
6267
6830
  console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- <deploy command>"));
6268
6831
  console.error(color("dim", "Example: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
6832
+ console.error(color("dim", "Tip: run `npx pqcheck setup --auto --domain <domain>` once and the domain is saved to .cipherwake.json — after that, `npx pqcheck guard -- <deploy-cmd>` works without the flag."));
6269
6833
  process.exit(3);
6270
6834
  }
6271
6835
  if (deployCmd.length === 0) {
@@ -6342,7 +6906,15 @@ async function runGuardCommand(args) {
6342
6906
  console.log(color("bold", ` Pre-deploy check returned: ship_decision=${shipDecision}`));
6343
6907
 
6344
6908
  // Decide what to do.
6345
- if (shipDecision === "pass") {
6909
+ if (gateMode === "advisory" && shipDecision !== "pass") {
6910
+ // R96 — advisory mode promises "deploy never blocked", but --fail-on
6911
+ // none only downgrades FINDINGS-driven decisions; an assertion-driven
6912
+ // block (deploy unreachable, WAF) still arrived here as block/review
6913
+ // and hit process.exit below. Advisory means advisory: warn + proceed.
6914
+ console.log(color("yellow", ` ⚠ Advisory mode: ship_decision=${shipDecision} noted — proceeding with deploy anyway.`));
6915
+ console.log(color("dim", " Review the findings above. Advisory mode never blocks the deploy."));
6916
+ console.log("");
6917
+ } else if (shipDecision === "pass") {
6346
6918
  console.log(color("green", " ✓ Posture stable — running deploy command."));
6347
6919
  console.log("");
6348
6920
  } else if (shipDecision === "review") {
@@ -6465,6 +7037,15 @@ Cipherwake deploy check and route on the result.
6465
7037
  Reference: https://cipherwake.io/methodology/ai-coder-protocol
6466
7038
  `;
6467
7039
 
7040
+ // R96 (dogfood feedback #2) — fill the <your-domain> placeholders with the
7041
+ // real monitored domain when known. A protocol block reading `deploy-check
7042
+ // cipherwake.io --ai` is directly executable by the AI coder; the literal
7043
+ // `<your-domain>` placeholder forced every AI session to guess or ask.
7044
+ function renderProtocolText(domain) {
7045
+ if (!domain) return AI_CODER_PROTOCOL_TEXT;
7046
+ return AI_CODER_PROTOCOL_TEXT.replaceAll("<your-domain>", domain);
7047
+ }
7048
+
6468
7049
  async function runProtocolCommand(args) {
6469
7050
  const sub = args[0];
6470
7051
  if (sub !== "install") {
@@ -6511,6 +7092,21 @@ async function runProtocolCommand(args) {
6511
7092
  process.exit(3);
6512
7093
  }
6513
7094
 
7095
+ // R96 — resolve the monitored domain (--domain flag, else .cipherwake.json)
7096
+ // so the installed protocol text carries the real domain instead of the
7097
+ // <your-domain> placeholder.
7098
+ let protocolDomain = null;
7099
+ const domainFlag = parseFlag(args, "--domain");
7100
+ if (domainFlag) {
7101
+ const d = normalizeDomain(domainFlag);
7102
+ if (isValidDomain(d)) {
7103
+ protocolDomain = d;
7104
+ } else {
7105
+ console.error(color("yellow", `⚠ --domain "${domainFlag}" is not a valid hostname — protocol will keep the <your-domain> placeholder`));
7106
+ }
7107
+ }
7108
+ if (!protocolDomain) protocolDomain = await readCipherwakeConfigDomain();
7109
+
6514
7110
  // Detect candidate files across major AI coders that:
6515
7111
  // (a) read an instructions file at session start, AND
6516
7112
  // (b) can run shell commands (so they can actually invoke pqcheck).
@@ -6548,6 +7144,10 @@ async function runProtocolCommand(args) {
6548
7144
  if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
6549
7145
  console.log("");
6550
7146
  }
7147
+ if (protocolDomain) {
7148
+ console.log(color("dim", `Domain: ${protocolDomain} — the protocol's <your-domain> placeholders will be filled in.`));
7149
+ console.log("");
7150
+ }
6551
7151
  console.log("Here's what would be added:");
6552
7152
  console.log("");
6553
7153
  if (detected.length === 0) {
@@ -6569,7 +7169,17 @@ async function runProtocolCommand(args) {
6569
7169
  }
6570
7170
  detected.push({ label: useGlobal ? "Claude Code (will create global)" : "Claude Code (will create project)", path: fallbackPath });
6571
7171
  }
7172
+ // R96 — Claude Code reads BOTH ~/.claude/CLAUDE.md and ./CLAUDE.md, so
7173
+ // installing to both duplicates ~40 lines in every session's context.
7174
+ // When the global file is a target, the project CLAUDE.md is skipped.
7175
+ const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
7176
+ const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
7177
+ const globalClaudeMdDetected = detected.some((d) => d.path === globalClaudeMdPath);
6572
7178
  for (const d of detected) {
7179
+ if (globalClaudeMdDetected && d.path === projectClaudeMdPath) {
7180
+ console.log(` • ${color("dim", `Skip ${d.path} — covered by the global Claude Code install (Claude Code reads both files; one copy is enough)`)}`);
7181
+ continue;
7182
+ }
6573
7183
  console.log(` • Append a ~30-line "## Pre-deploy verification with Cipherwake" section to ${color("bold", d.path)}`);
6574
7184
  console.log(` ${color("dim", `(${d.label} — existing content preserved)`)}`);
6575
7185
  }
@@ -6578,7 +7188,7 @@ async function runProtocolCommand(args) {
6578
7188
  // --manual → print only, no install
6579
7189
  if (manualFlag) {
6580
7190
  console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
6581
- console.log(AI_CODER_PROTOCOL_TEXT);
7191
+ console.log(renderProtocolText(protocolDomain));
6582
7192
  console.log(color("bold", "── End of protocol ──"));
6583
7193
  console.log("");
6584
7194
  console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
@@ -6593,6 +7203,7 @@ async function runProtocolCommand(args) {
6593
7203
  mode: "auto-flag",
6594
7204
  consent_phrase: consentPhrase,
6595
7205
  invoked_by: invokedBy,
7206
+ domain: protocolDomain,
6596
7207
  fs, path, os,
6597
7208
  });
6598
7209
  }
@@ -6631,7 +7242,7 @@ async function runProtocolCommand(args) {
6631
7242
  if (choice === "m" || choice === "manual") {
6632
7243
  console.log("");
6633
7244
  console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
6634
- console.log(AI_CODER_PROTOCOL_TEXT);
7245
+ console.log(renderProtocolText(protocolDomain));
6635
7246
  console.log(color("bold", "── End of protocol ──"));
6636
7247
  console.log("");
6637
7248
  console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
@@ -6643,6 +7254,7 @@ async function runProtocolCommand(args) {
6643
7254
  mode: "interactive",
6644
7255
  consent_phrase: "user typed [a] at the install prompt",
6645
7256
  invoked_by: "human-at-terminal",
7257
+ domain: protocolDomain,
6646
7258
  fs, path, os,
6647
7259
  });
6648
7260
  }
@@ -6655,8 +7267,22 @@ async function runProtocolCommand(args) {
6655
7267
  // Writes the protocol to each detected file and records the audit trail.
6656
7268
  async function performAutoInstall(detected, opts) {
6657
7269
  const { fs, path, os } = opts;
7270
+ const protocolText = renderProtocolText(opts.domain || null);
6658
7271
  const results = [];
7272
+ // R96 — cross-file dedupe: Claude Code reads BOTH ~/.claude/CLAUDE.md and
7273
+ // ./CLAUDE.md. Once the global file carries the protocol (pre-existing or
7274
+ // installed earlier in this loop — global is first in the candidates
7275
+ // order), the project CLAUDE.md is skipped instead of duplicating ~40
7276
+ // lines into every session's context.
7277
+ const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
7278
+ const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
7279
+ let globalClaudeMdCovered = false;
6659
7280
  for (const d of detected) {
7281
+ if (d.path === projectClaudeMdPath && globalClaudeMdCovered) {
7282
+ console.log(color("dim", ` ⊝ ${d.path} — covered by the global Claude Code install (~/.claude/CLAUDE.md), skipping.`));
7283
+ results.push({ path: d.path, label: d.label, status: "skipped-covered-by-global" });
7284
+ continue;
7285
+ }
6660
7286
  try {
6661
7287
  await fs.mkdir(path.dirname(d.path), { recursive: true });
6662
7288
  let existing = "";
@@ -6664,12 +7290,14 @@ async function performAutoInstall(detected, opts) {
6664
7290
  if (existing.includes("## Pre-deploy verification with Cipherwake")) {
6665
7291
  console.log(color("dim", ` ⊝ ${d.path} — already contains the protocol, skipping.`));
6666
7292
  results.push({ path: d.path, label: d.label, status: "skipped-already-present" });
7293
+ if (d.path === globalClaudeMdPath) globalClaudeMdCovered = true;
6667
7294
  continue;
6668
7295
  }
6669
- const newContent = existing + "\n" + AI_CODER_PROTOCOL_TEXT + "\n";
7296
+ const newContent = existing + "\n" + protocolText + "\n";
6670
7297
  await fs.writeFile(d.path, newContent, "utf8");
6671
- console.log(color("green", ` ✓ ${d.path} — protocol appended (${AI_CODER_PROTOCOL_TEXT.split("\n").length} lines)`));
7298
+ console.log(color("green", ` ✓ ${d.path} — protocol appended (${protocolText.split("\n").length} lines)`));
6672
7299
  results.push({ path: d.path, label: d.label, status: "installed" });
7300
+ if (d.path === globalClaudeMdPath) globalClaudeMdCovered = true;
6673
7301
  } catch (err) {
6674
7302
  console.log(color("red", ` ✗ ${d.path} — failed: ${err.message}`));
6675
7303
  results.push({ path: d.path, label: d.label, status: "failed", error: String(err?.message || err) });
@@ -6921,6 +7549,12 @@ async function runSetupCommand(args) {
6921
7549
  console.log("");
6922
7550
  console.log(color("bold", "Files this install would touch:"));
6923
7551
  const planEntries = [];
7552
+ {
7553
+ const cfgPath = path.join(process.cwd(), ".cipherwake.json");
7554
+ let cfgExists = false;
7555
+ try { await fs.access(cfgPath); cfgExists = true; } catch { /* */ }
7556
+ planEntries.push({ what: `.cipherwake.json domain default (${domain})`, to: cfgPath, op: cfgExists ? "merge domain field" : "create" });
7557
+ }
6924
7558
  if (!skipWorkflow) planEntries.push({ what: "GitHub Action workflow", to: path.join(process.cwd(), ".github", "workflows", "cipherwake.yml"), op: "create" });
6925
7559
  if (!skipProtocol) {
6926
7560
  const candidates = [
@@ -6931,10 +7565,15 @@ async function runSetupCommand(args) {
6931
7565
  { label: "Aider", path: path.join(process.cwd(), ".aider.conf.yml") },
6932
7566
  { label: "AGENTS.md", path: path.join(process.cwd(), "AGENTS.md") },
6933
7567
  ];
7568
+ let globalClaudeMdExists = false;
7569
+ try { await fs.access(path.join(os.homedir(), ".claude", "CLAUDE.md")); globalClaudeMdExists = true; } catch { /* */ }
6934
7570
  for (const c of candidates) {
6935
7571
  let exists = false;
6936
7572
  try { await fs.access(c.path); exists = true; } catch { /* */ }
6937
- planEntries.push({ what: `AI Coder Protocol — ${c.label}`, to: c.path, op: exists ? "append-markered" : "skip (file not present)" });
7573
+ let op = exists ? "append-markered" : "skip (file not present)";
7574
+ // R96 — Claude Code reads both global + project CLAUDE.md; one copy is enough.
7575
+ if (c.label === "Claude Code (project)" && globalClaudeMdExists) op = "skip (covered by global CLAUDE.md)";
7576
+ planEntries.push({ what: `AI Coder Protocol — ${c.label}`, to: c.path, op });
6938
7577
  }
6939
7578
  }
6940
7579
  if (!skipHook) planEntries.push({ what: "git pre-push hook", to: path.join(process.cwd(), ".git", "hooks", "pre-push"), op: "create (if git repo)" });
@@ -7024,6 +7663,40 @@ async function runSetupCommand(args) {
7024
7663
 
7025
7664
  const installSummary = [];
7026
7665
 
7666
+ // -------------------------------------------------------------------------
7667
+ // Component 0: persist the domain to ./.cipherwake.json (R96, feedback #2)
7668
+ // -------------------------------------------------------------------------
7669
+ // Once the domain lives in the config, `pqcheck deploy-check --ai` and
7670
+ // `pqcheck guard -- <cmd>` work without a domain argument — the AI coder
7671
+ // never has to guess which domain this repo deploys to.
7672
+ try {
7673
+ const cfgResult = await writeDomainToCipherwakeConfig(domain);
7674
+ switch (cfgResult.status) {
7675
+ case "created":
7676
+ console.log(color("green", ` ✓ wrote .cipherwake.json (domain: ${domain} — deploy-check/guard can now omit the domain argument)`));
7677
+ break;
7678
+ case "merged":
7679
+ console.log(color("green", ` ✓ added "domain": "${domain}" to .cipherwake.json`));
7680
+ break;
7681
+ case "updated":
7682
+ console.log(color("green", ` ✓ updated .cipherwake.json domain: ${cfgResult.prior} → ${domain}`));
7683
+ break;
7684
+ case "already-set":
7685
+ console.log(color("dim", ` ⊝ .cipherwake.json already sets domain ${domain} — skipping`));
7686
+ break;
7687
+ case "skipped-malformed":
7688
+ console.log(color("yellow", ` ⚠ .cipherwake.json has malformed JSON — not writing domain (fix the file, then re-run, or add "domain": "${domain}" by hand)`));
7689
+ break;
7690
+ case "skipped-unexpected-shape":
7691
+ console.log(color("yellow", ` ⚠ .cipherwake.json is not a JSON object — not writing domain`));
7692
+ break;
7693
+ }
7694
+ installSummary.push({ component: ".cipherwake.json domain", path: cfgResult.path, status: cfgResult.status });
7695
+ } catch (err) {
7696
+ console.log(color("red", ` ✗ .cipherwake.json domain write failed: ${err.message}`));
7697
+ installSummary.push({ component: ".cipherwake.json domain", status: "failed", error: String(err?.message || err) });
7698
+ }
7699
+
7027
7700
  // -------------------------------------------------------------------------
7028
7701
  // Component 1: GitHub Action workflow (.github/workflows/cipherwake.yml)
7029
7702
  // -------------------------------------------------------------------------
@@ -7035,7 +7708,10 @@ async function runSetupCommand(args) {
7035
7708
  console.log(color("dim", ` ⊝ workflow at .github/workflows/cipherwake.yml already exists — skipping`));
7036
7709
  installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "skipped-already-present" });
7037
7710
  } catch {
7038
- const workflowYaml = renderTrustDiffWorkflow({ domain, failOn, baseline });
7711
+ // R94.3 setup-time workflow inherits the same opt-in default
7712
+ // (push). Customers on Vercel/Netlify can re-run `pqcheck init
7713
+ // --trigger=deployment-status` to swap in the post-deploy variant.
7714
+ const workflowYaml = renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode: "push" });
7039
7715
  await fs.mkdir(path.dirname(workflowPath), { recursive: true });
7040
7716
  await fs.writeFile(workflowPath, workflowYaml, "utf8");
7041
7717
  console.log(color("green", ` ✓ wrote .github/workflows/cipherwake.yml (CI hard-gate layer)`));
@@ -7083,7 +7759,22 @@ async function runSetupCommand(args) {
7083
7759
  // user has written outside the markers.
7084
7760
  const START_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START — managed by pqcheck setup; safe to delete this section between markers but do not edit by hand -->";
7085
7761
  const END_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_END -->";
7762
+ // R96 — setup always has --domain; render it into the protocol so the
7763
+ // installed text is directly executable (no <your-domain> placeholder).
7764
+ const protocolText = renderProtocolText(domain);
7765
+ // R96 — cross-file dedupe: Claude Code reads BOTH ~/.claude/CLAUDE.md and
7766
+ // ./CLAUDE.md. Global is first in the candidates order; once it carries
7767
+ // the protocol, skip the project CLAUDE.md instead of duplicating ~40
7768
+ // lines into every session's context.
7769
+ const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
7770
+ const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
7771
+ let globalClaudeMdCovered = false;
7086
7772
  for (const t of protocolTargets) {
7773
+ if (t.path === projectClaudeMdPath && globalClaudeMdCovered) {
7774
+ console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — covered by the global Claude Code install (~/.claude/CLAUDE.md)`));
7775
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-covered-by-global" });
7776
+ continue;
7777
+ }
7087
7778
  try {
7088
7779
  await fs.mkdir(path.dirname(t.path), { recursive: true });
7089
7780
  let existing = "";
@@ -7098,10 +7789,11 @@ async function runSetupCommand(args) {
7098
7789
  const endIdx = existing.indexOf(END_MARKER) + END_MARKER.length;
7099
7790
  const before = existing.slice(0, startIdx);
7100
7791
  const after = existing.slice(endIdx);
7101
- const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n${after.replace(/^\n+/, "")}`;
7792
+ const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${protocolText}\n${END_MARKER}\n${after.replace(/^\n+/, "")}`;
7102
7793
  if (next === existing) {
7103
7794
  console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — protocol already current`));
7104
7795
  installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-already-present" });
7796
+ if (t.path === globalClaudeMdPath) globalClaudeMdCovered = true;
7105
7797
  continue;
7106
7798
  }
7107
7799
  await fs.writeFile(t.path, next, "utf8");
@@ -7115,10 +7807,11 @@ async function runSetupCommand(args) {
7115
7807
  } else {
7116
7808
  // Fresh install — append with fenced markers.
7117
7809
  const sep = existing.length > 0 ? "\n\n" : "";
7118
- await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n`, "utf8");
7810
+ await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${protocolText}\n${END_MARKER}\n`, "utf8");
7119
7811
  console.log(color("green", ` ✓ appended protocol (markered) → ${path.basename(t.path)} (${t.label})`));
7120
7812
  installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed" });
7121
7813
  }
7814
+ if (t.path === globalClaudeMdPath) globalClaudeMdCovered = true;
7122
7815
  } catch (err) {
7123
7816
  console.log(color("red", ` ✗ ${t.path} — failed: ${err.message}`));
7124
7817
  installSummary.push({ component: "AI Coder Protocol", path: t.path, status: "failed", error: String(err?.message || err) });
@@ -7501,14 +8194,15 @@ async function runSetupCommand(args) {
7501
8194
  console.log(color("bold", "◆ Setup complete — summary:"));
7502
8195
  console.log("");
7503
8196
  for (const s of installSummary) {
7504
- const icon = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
8197
+ const okStatuses = ["installed", "installed-created", "installed-updated", "created", "merged", "updated"];
8198
+ const icon = okStatuses.includes(s.status)
7505
8199
  ? color("green", "✓")
7506
- : s.status?.startsWith("skipped")
8200
+ : s.status?.startsWith("skipped") || s.status === "already-set"
7507
8201
  ? color("dim", "⊝")
7508
8202
  : color("red", "✗");
7509
8203
  const label = s.component;
7510
- const detail = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
7511
- ? color("dim", `installed${s.path ? " → " + s.path.replace(os.homedir(), "~") : ""}`)
8204
+ const detail = okStatuses.includes(s.status)
8205
+ ? color("dim", `${s.status.startsWith("installed") ? "installed" : s.status}${s.path ? " → " + s.path.replace(os.homedir(), "~") : ""}`)
7512
8206
  : color("dim", s.status?.replace(/-/g, " "));
7513
8207
  console.log(` ${icon} ${label.padEnd(34, " ")} ${detail}`);
7514
8208
  }
@@ -7521,5 +8215,27 @@ async function runSetupCommand(args) {
7521
8215
 
7522
8216
  main().catch((err) => {
7523
8217
  console.error(color("red", `fatal: ${err.message}`));
7524
- process.exit(2);
8218
+ // R96 — exit 3 (error), NOT 2. Exit 2 means "block" in the AI contract,
8219
+ // so an internal CLI crash used to masquerade as a security block (and
8220
+ // the pre-push hook refused the push). In AI mode, also emit a guard
8221
+ // block so the agent gets ship_decision=review instead of "no signal".
8222
+ try {
8223
+ const argv = process.argv.slice(2);
8224
+ if (parseAiMode(argv)) {
8225
+ console.log(formatAiFooterBlock({
8226
+ status: "review",
8227
+ kind: "error",
8228
+ verdict: "review",
8229
+ ship_decision: "review",
8230
+ top_issue: "cli_internal_error",
8231
+ top_issue_title: "pqcheck crashed before producing a result",
8232
+ scanned_at: new Date().toISOString(),
8233
+ advisory_only: "true",
8234
+ error: String(err?.message || err).slice(0, 200),
8235
+ }));
8236
+ }
8237
+ } catch {
8238
+ // never let the crash handler crash
8239
+ }
8240
+ process.exit(3);
7525
8241
  });