pqcheck 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,8 +25,51 @@ The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser
25
25
  | `npx pqcheck preview-diff --preview <URL> --production <URL>` | Compare a Vercel/Netlify preview deployment URL to production. Surfaces new third-party scripts, header regressions, and DBR score drops *inside the PR*, before merge. |
26
26
  | `npx pqcheck vendors export/check/sync <domain>` | Vendor lockfile (`cipherwake.vendors.json`) + CI gate that exits non-zero when a new third-party origin appears. Like `package-lock.json` for vendor scripts. |
27
27
  | `npx pqcheck onboard <domain>` | One command: scan → scaffold the GitHub Action → capture a vendor lockfile → set a baseline → commit + push. Zero copy-paste from docs. |
28
+ | **`npx pqcheck guard --domain <D> -- <cmd>`** 🆕 | **Deploy guard wrapper.** Wraps any deploy command. Runs `deploy-check` first; conditionally runs `<cmd>` based on `ship_decision`. Modes: `--gate-mode balanced` (default) / `advisory` / `strict`. ONE command instead of two — the strongest single artifact for AI-coder workflows because the AI never has to remember to chain check + deploy. |
29
+ | **`npx pqcheck protocol install`** 🆕 | **Opt-in installer** for the AI Coder Protocol — appends the pre-deploy verification rule to your `CLAUDE.md` / `.cursorrules` / `.aider.conf.yml` with explicit consent (Rule 17). One upfront question (auto / manual / no). Never silent writes. |
30
+ | **`npx pqcheck setup --auto --domain <D>`** 🆕 | **One-command full setup for every AI coder.** Installs (idempotently): GitHub Action workflow, AI Coder Protocol across all detected rules files (Claude / Cursor / Copilot / Aider / Windsurf / Continue / Cline / AGENTS.md) using fenced markers (`<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START/END -->`), git pre-push hook, Claude Code statusLine + 2 hooks (PostToolUse Bash + **UserPromptSubmit** ⓝ), per-repo `.cipherwake/last-status.json` for Cursor / Copilot / Continue to read as context. Skip flags available. Backups taken before any `~/.claude/settings.json` write. Audit trail at `~/.config/cipherwake/install-prefs.json`; install manifest at `~/.config/cipherwake/install-manifest.json`. |
31
+ | **`UserPromptSubmit` hook** (v0.15.1 ⓝ) | **Claude sees `ship_decision` before responding to every prompt.** When `pqcheck setup --auto` runs, it wires `cipherwake-prompt-hook` as a Claude Code UserPromptSubmit hook. On every user prompt, the hook injects `additionalContext` with the current scan's `ship_decision` IF it's `review`/`block` and the state is <24h old. Silent when state is missing, stale, or `pass`. Different timing from the PostToolUse chat-hook: this fires *before* Claude thinks (proactive), the chat-hook fires *after* a Bash command (reactive). |
32
+ | **Per-repo state file** `.cipherwake/last-status.json` (v0.15.1 ⓝ) | **Cursor / Copilot / Continue read this for workspace context.** Every `pqcheck` scan writes the same payload as the per-user file. Created by `setup --auto`; auto-added to `.gitignore` (per-developer state, not committable). Gives AI agents inside VS Code-family editors a repo-local artifact they pick up automatically when reading workspace files. |
33
+ | **`npx pqcheck setup --plan --domain <D>`** 🆕 | **Dry-run mode.** Prints every file change `--auto` would make (target paths + operation type: create / append-markered / deep-merge / backup-first) without writing anything. Run this first when you're not sure what `--auto` will touch. |
34
+ | **`npx pqcheck debug-network`** 🆕 | **Connectivity diagnostic.** Probes cipherwake.io API, homepage, crt.sh upstream, and the direct Vercel URL (bypassing Cloudflare). Reports HTTP status + timing per hop. Use when "scan hung" / "command not found" / corporate proxy issues come up — surfaces the actual broken hop with an actionable cause list. |
35
+ | **`--ai` flag** (any of the above) | **AI Coder Mode** (0.15.0). Three-layer output (banner / body / structured `CIPHERWAKE_AI_GUARD_RESULT` block) tuned for Claude Code / Cursor / Aider / Zed. Includes a `ship_decision=pass\|review\|block` field your AI coworker parses to decide whether to announce the deploy, ask you, or revert. See [/methodology/ai-coder-mode](https://cipherwake.io/methodology/ai-coder-mode). |
28
36
 
29
- Free tier covers all of the above within 100 Trust Diff calls/month per repo (paid lifts to 1K / 10K / 50K). Single-domain scans (`npx pqcheck <domain>`) are unmetered.
37
+ Free tier covers all of the above within 100 Trust Diff calls/month per repo via OIDC. **Founder Pro** ($19.99/mo, locked while subscription active) raises that to 5,000 calls/month + unlocks custom thresholds, vendor lockfile, CI fail rules, and 5 watched domains. Single-domain scans (`npx pqcheck <domain>`) are anonymous + rate-limited per IP — no account or key needed. `npx pqcheck deploy-check <domain> --ai` also works fully anonymously for first-deploy gating.
38
+
39
+ ### AI Coder Mode in 30 seconds
40
+
41
+ ```bash
42
+ npx pqcheck cipherwake.io --ai
43
+ ```
44
+
45
+ Output:
46
+
47
+ ```
48
+ ◆ cipherwake · scan · ⚠ REVIEW · cipherwake.io · DBR 4.1 C · HIGH · ship_decision=review
49
+
50
+ Top finding:
51
+ [HIGH] ECDHE-only — quantum-vulnerable key exchange
52
+
53
+ Why it matters:
54
+ Forward-secret against classical attackers. Shor's algorithm decrypts
55
+ recorded handshakes once a CRQC exists.
56
+
57
+ Recommended next action:
58
+ Review finding above and decide if it was intentional.
59
+ View full report: https://cipherwake.io/r/cipherwake.io
60
+ Re-scan with --fresh after fix: npx pqcheck cipherwake.io --fresh --ai
61
+
62
+ CIPHERWAKE_AI_GUARD_RESULT
63
+ status=review
64
+ domain=cipherwake.io
65
+ ship_decision=review
66
+ max_severity=high
67
+ top_issue=tls.ecdhe_only_quantum_vulnerable
68
+ advisory_only=true
69
+ END_CIPHERWAKE_AI_GUARD_RESULT
70
+ ```
71
+
72
+ The structured block is what your AI coworker (Claude / Cursor / Aider / Zed) parses to decide whether to announce the deploy, ask you, or revert. Exit code in `--ai` mode reflects `ship_decision`: `0` pass · `1` review · `2` block.
30
73
 
31
74
  ---
32
75
 
@@ -56,7 +99,7 @@ git push
56
99
 
57
100
  That's it. The scaffolded workflow includes `permissions: id-token: write`, so the runner mints a signed OIDC token on each run and Cipherwake meters per repo — no secret to manage. Open a PR and Cipherwake comments inline when cert / SPKI / HSTS / CSP / DMARC / vendor scripts drift since your baseline.
58
101
 
59
- **Need higher limits?** Paid tiers (Starter $29/mo · Growth $79/mo · Scale $199/mo) lift the per-repo quota to 1,000 / 10,000 / 50,000 calls/month. Generate an API key at [/account#api-keys](https://cipherwake.io/account#api-keys), then add it as the repo secret `CIPHERWAKE_API_KEY`. The Action uses the secret when present and falls back to OIDC when not — no code change needed to upgrade.
102
+ **Need higher limits?** **Founder Pro ($19.99/mo)** lifts the per-repo quota to 5,000 calls/month and unlocks custom thresholds, the approved-vendor allowlist, vendor lockfile, CI fail rules, and 5 watched domains. Generate an API key at [/account#api-keys](https://cipherwake.io/account#api-keys), then add it as the repo secret `CIPHERWAKE_API_KEY`. The Action uses the secret when present and falls back to OIDC when not — no code change needed to upgrade. *Founder pricing is locked while your subscription remains active.*
60
103
 
61
104
  **Want more?**
62
105
  - Pre-commit hook: `npx pqcheck deploy-check <domain>` before every deploy
@@ -77,7 +120,7 @@ npx pqcheck trust-diff mycompany.com --baseline last-week --fail-on high
77
120
 
78
121
  Compares today's public trust posture against a configured baseline (`last-week`, `last-month`, or a saved per-branch baseline). Surfaces cert / SPKI / HSTS / CSP / DMARC / vendor-script drift since the baseline and gates the PR by severity. SARIF output uploads to GitHub Code Scanning. Pair with the [GitHub Action](https://github.com/cipherwakelabs/pqcheck/tree/main/action) `mode: trust-diff` for one-line CI integration.
79
122
 
80
- Exit codes: `0` pass · `1` warn · `2` fail · `3` error. Free tier (100 calls/repo/mo via GitHub Actions OIDC, no API key required) silently downgrades fail → report; Starter+ honors `--fail-on` for real CI gating.
123
+ Exit codes: `0` pass · `1` warn · `2` fail · `3` error. Free tier (100 calls/repo/mo via GitHub Actions OIDC, no API key required) silently downgrades fail → report; **Founder Pro** honors `--fail-on` for real CI gating.
81
124
 
82
125
  ### Preview Trust Diff — PR-time URL-vs-URL comparison
83
126
 
@@ -131,7 +174,7 @@ Like `package-lock.json`, but for the third-party scripts that load on your doma
131
174
  ```bash
132
175
  npx pqcheck vendors export mycompany.com # write cipherwake.vendors.json
133
176
  npx pqcheck vendors check mycompany.com # CI gate; exit 4 on new origins
134
- npx pqcheck vendors sync mycompany.com # Starter+ — pull dashboard allowlist
177
+ npx pqcheck vendors sync mycompany.com # Founder Pro — pull dashboard allowlist
135
178
  ```
136
179
 
137
180
  `pqcheck deps` also surfaces a one-line site-wide **CSP verdict** above the supply-chain table (`✗ No CSP enforcement` / `⚠ CSP is permissive` / `✓ Strict CSP enforced`) and friendly vendor labels (`New Relic · errors` / `Cloudflare · cdn` / `Adobe Fonts · fonts`) instead of raw hostnames. Same data shape ships on `/r/<domain>` and in the browser extension.
@@ -182,8 +225,14 @@ npx pqcheck init Interactive scaffold for .github/w
182
225
  npx pqcheck release-checklist [domain] Pre-release trust checklist (markdown, offline)
183
226
  npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
184
227
  npx pqcheck vendors check <domain> CI gate; exit 4 on new origins not in lockfile
185
- npx pqcheck vendors sync <domain> Pull dashboard allowlist into lockfile (Starter+, needs API key)
228
+ npx pqcheck vendors sync <domain> Pull dashboard allowlist into lockfile (Founder Pro, needs API key)
186
229
  npx pqcheck watch <domain> Add domain to your watched list (needs CIPHERWAKE_API_KEY)
230
+ npx pqcheck guard --domain <D> -- <cmd> AI Coder Mode (0.15.0) — wrap any deploy command with a Trust Diff gate
231
+ npx pqcheck deploy-check <D> --ai AI Coder Mode (0.15.0) — frictionless first-deploy, anonymous, emits CIPHERWAKE_AI_GUARD_RESULT block
232
+ npx pqcheck setup --auto --domain <D> AI Coder Mode (0.15.0) — one-command install across CLAUDE.md/.cursorrules/.github + git pre-push hook + statusline
233
+ npx pqcheck setup --plan --domain <D> AI Coder Mode (0.15.0) — dry-run: print every file change before --auto writes anything
234
+ npx pqcheck protocol install --auto AI Coder Mode (0.15.0) — append AI Coder Protocol to detected rules files (idempotent, fenced markers)
235
+ npx pqcheck debug-network Probe upstream connectivity (cipherwake.io / crt.sh / Vercel) — for "scan hung" diagnosis
187
236
  ```
188
237
 
189
238
  ### Multi-domain
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // cipherwake-chat-hook — Claude Code PostToolUse hook
4
+ // =============================================================================
5
+ // Reads stdin (tool event JSON from Claude Code), checks if the tool was a
6
+ // pqcheck-related Bash command, reads the latest scan state, emits a
7
+ // systemMessage to Claude Code chat.
8
+ //
9
+ // Wire it up by adding to ~/.claude/settings.json:
10
+ //
11
+ // "hooks": {
12
+ // "PostToolUse": [{
13
+ // "matcher": "Bash",
14
+ // "hooks": [{
15
+ // "type": "command",
16
+ // "command": "npx cipherwake-chat-hook"
17
+ // }]
18
+ // }]
19
+ // }
20
+ //
21
+ // `pqcheck setup --auto` does this for you (idempotently, merging with any
22
+ // existing hook configs per CLAUDE.md Rule 17).
23
+ //
24
+ // Behavior:
25
+ // * Only emits a message if the tool was Bash + the command invoked pqcheck
26
+ // * Only emits if last-scan.json was updated within the last 60s (i.e. this
27
+ // pqcheck invocation actually changed state) — avoids spamming chat for
28
+ // stale state
29
+ // * Single line output for status-bar-style readability
30
+ // =============================================================================
31
+
32
+ import { readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { homedir } from "node:os";
35
+
36
+ // Hooks receive event JSON on stdin. If missing / malformed, exit silently.
37
+ let toolEvent;
38
+ try {
39
+ toolEvent = JSON.parse(readFileSync(0, "utf8"));
40
+ } catch {
41
+ process.exit(0);
42
+ }
43
+
44
+ // Only react to Bash tool uses that invoked pqcheck or cipherwake-statusline.
45
+ if (toolEvent?.tool_name !== "Bash") {
46
+ process.exit(0);
47
+ }
48
+ const command = String(toolEvent.tool_input?.command || "");
49
+ const isPqcheck =
50
+ /\bpqcheck\b/.test(command) ||
51
+ /\bcipherwake-statusline\b/.test(command);
52
+ if (!isPqcheck) {
53
+ process.exit(0);
54
+ }
55
+
56
+ // Read the last-scan state. If missing, exit silently.
57
+ let state;
58
+ try {
59
+ state = JSON.parse(
60
+ readFileSync(join(homedir(), ".config", "cipherwake", "last-scan.json"), "utf8"),
61
+ );
62
+ } catch {
63
+ process.exit(0);
64
+ }
65
+
66
+ // Only emit if the state was updated recently (<60s). Otherwise we'd narrate
67
+ // stale state on every unrelated Bash command, which would be obnoxious.
68
+ const writtenAt = new Date(state.written_at).getTime();
69
+ if (!Number.isFinite(writtenAt)) process.exit(0);
70
+ if (Date.now() - writtenAt > 60_000) process.exit(0);
71
+
72
+ const sd = state.ship_decision || "—";
73
+ const emoji = sd === "pass" ? "✓" : sd === "block" ? "✗" : "⚠";
74
+
75
+ const parts = [`◆ Cipherwake: ${emoji} ${state.domain} ship_decision=${sd}`];
76
+ if (typeof state.score === "number") parts.push(`DBR ${state.score.toFixed(1)}${state.grade ? " " + state.grade : ""}`);
77
+ if (state.max_severity && state.max_severity !== "none") parts.push(String(state.max_severity).toUpperCase());
78
+ if (state.top_issue && state.top_issue !== "none") parts.push(`top: ${state.top_issue}`);
79
+
80
+ const message = parts.join(" · ");
81
+
82
+ // Output JSON to stdout — Claude Code reads the `systemMessage` field and
83
+ // displays it to the user in the chat scrollback.
84
+ process.stdout.write(
85
+ JSON.stringify({
86
+ systemMessage: message,
87
+ suppressOutput: true,
88
+ }),
89
+ );
90
+ process.exit(0);
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // cipherwake-prompt-hook — Claude Code UserPromptSubmit hook (v0.15.1 parity)
4
+ // =============================================================================
5
+ // Fires BEFORE Claude responds to every user prompt. Injects the latest scan
6
+ // state from ~/.config/cipherwake/last-scan.json into Claude's context, so
7
+ // the AI sees ship_decision proactively (e.g., when the user says "ok deploy
8
+ // this", Claude already knows the trust posture).
9
+ //
10
+ // Different timing from cipherwake-chat-hook (PostToolUse Bash) — that one
11
+ // fires AFTER a tool runs and pushes a chat message reactively. This one
12
+ // fires BEFORE the model thinks, by injecting additionalContext via the
13
+ // hookSpecificOutput shape.
14
+ //
15
+ // Silent (no injection) when:
16
+ // • state file missing
17
+ // • state file > 24h old (don't anchor on stale data)
18
+ // • ship_decision === "pass" (no need to spam the model with good news)
19
+ //
20
+ // Wire-up via `pqcheck setup --auto` writes the entry to
21
+ // ~/.claude/settings.json under `hooks.UserPromptSubmit`.
22
+ // =============================================================================
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { homedir } from "node:os";
27
+
28
+ const STATE_FILE = process.env.CIPHERWAKE_STATE_FILE
29
+ || join(homedir(), ".config", "cipherwake", "last-scan.json");
30
+
31
+ const MAX_STATE_AGE_MS = 24 * 60 * 60 * 1000; // 24h — older than this, don't inject
32
+
33
+ function readStdin() {
34
+ return new Promise((resolve) => {
35
+ let data = "";
36
+ process.stdin.setEncoding("utf8");
37
+ process.stdin.on("data", (chunk) => (data += chunk));
38
+ process.stdin.on("end", () => resolve(data));
39
+ // If no stdin (manual invocation), resolve empty immediately
40
+ if (process.stdin.isTTY) resolve("");
41
+ });
42
+ }
43
+
44
+ function silent() {
45
+ // Output nothing — Claude Code treats empty output as "no injection".
46
+ process.exit(0);
47
+ }
48
+
49
+ async function main() {
50
+ // Drain stdin (Claude Code sends a JSON event payload). We don't actually
51
+ // need the event content here — we just decide based on cached state.
52
+ try { await readStdin(); } catch { /* ignore */ }
53
+
54
+ let state;
55
+ try {
56
+ state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
57
+ } catch {
58
+ silent();
59
+ return;
60
+ }
61
+
62
+ // Freshness: only inject if the last scan is recent. Anchoring Claude on
63
+ // a week-old REVIEW state would be misleading after the user has likely
64
+ // already fixed the issue.
65
+ const writtenAt = state.written_at ? new Date(state.written_at).getTime() : 0;
66
+ if (!writtenAt || Date.now() - writtenAt > MAX_STATE_AGE_MS) {
67
+ silent();
68
+ return;
69
+ }
70
+
71
+ // Only inject for non-pass states. Don't spam the model with good news on
72
+ // every prompt — Claude already trusts that no news is good news.
73
+ const shipDecision = String(state.ship_decision || "").toLowerCase();
74
+ if (shipDecision !== "review" && shipDecision !== "block") {
75
+ silent();
76
+ return;
77
+ }
78
+
79
+ const emoji = shipDecision === "block" ? "⛔" : "⚠";
80
+ const sevLabel = shipDecision === "block" ? "BLOCK" : "REVIEW";
81
+ const dbr = typeof state.score === "number" ? state.score.toFixed(1) : "?";
82
+ const grade = state.grade || "?";
83
+ const maxSev = String(state.max_severity || "").toUpperCase();
84
+ const top = state.top_issue || "—";
85
+ const ago = Math.floor((Date.now() - writtenAt) / 60000);
86
+ const ageStr = ago < 1 ? "just now" : ago < 60 ? `${ago}m ago` : `${Math.floor(ago / 60)}h ago`;
87
+
88
+ // Build the context-injection block. Brevity matters — this lands in every
89
+ // user prompt's context, so it should be a one-paragraph reminder, not a
90
+ // multi-screen status report.
91
+ const additionalContext =
92
+ `${emoji} Cipherwake — latest scan of ${state.domain || "configured domain"}: ship_decision=${shipDecision} ` +
93
+ `(${sevLabel}, DBR ${dbr} ${grade}, max ${maxSev}, last scanned ${ageStr}). ` +
94
+ `Top issue: ${top}. ` +
95
+ `Before announcing a deploy, run \`npx pqcheck deploy-check ${state.domain || "<domain>"} --ai\` to refresh. ` +
96
+ `If ship_decision is still ${shipDecision}, surface the change to the user per the AI Coder Protocol ` +
97
+ `(https://cipherwake.io/methodology/ai-coder-protocol) — don't auto-announce.`;
98
+
99
+ // Claude Code's UserPromptSubmit hook accepts hookSpecificOutput.additionalContext
100
+ // which is injected into the model's input on this turn. The hook returns
101
+ // synchronously; suppressOutput hides anything on stdout from the UI.
102
+ process.stdout.write(JSON.stringify({
103
+ hookSpecificOutput: {
104
+ hookEventName: "UserPromptSubmit",
105
+ additionalContext,
106
+ },
107
+ suppressOutput: true,
108
+ }));
109
+ }
110
+
111
+ main().catch(() => silent());
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // cipherwake-statusline — reads ~/.config/cipherwake/last-scan.json and outputs
4
+ // a single-line summary for AI-coder status surfaces.
5
+ // =============================================================================
6
+ // Designed for Claude Code's `statusLine` setting (a config-level hook that
7
+ // runs any shell command and renders its stdout in the persistent status line).
8
+ // One-line install:
9
+ //
10
+ // add to ~/.claude/settings.json:
11
+ // { "statusLine": { "type": "command", "command": "npx cipherwake-statusline" } }
12
+ //
13
+ // Cipherwake never modifies your settings.json — paste the line yourself per
14
+ // CLAUDE.md Rule 17 (consolidated consent for any change outside our own
15
+ // config dir).
16
+ //
17
+ // The script is dependency-free + fast (<50ms on cold start) because Claude
18
+ // Code calls it on every turn. Reads a single file, formats one line, exits.
19
+ // =============================================================================
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { homedir } from "node:os";
24
+
25
+ const STATE_FILE = join(homedir(), ".config", "cipherwake", "last-scan.json");
26
+ const STALE_THRESHOLD_HOURS = 24;
27
+
28
+ const C = {
29
+ reset: "\x1b[0m",
30
+ bold: "\x1b[1m",
31
+ dim: "\x1b[2m",
32
+ green: "\x1b[32m",
33
+ yellow: "\x1b[33m",
34
+ red: "\x1b[31m",
35
+ cyan: "\x1b[36m",
36
+ };
37
+
38
+ function formatAge(iso) {
39
+ if (!iso) return "—";
40
+ const ms = Date.now() - new Date(iso).getTime();
41
+ if (ms < 0) return "just now";
42
+ if (ms < 60_000) return "just now";
43
+ const min = Math.floor(ms / 60_000);
44
+ if (min < 60) return `${min}m ago`;
45
+ const hr = Math.floor(min / 60);
46
+ if (hr < 24) return `${hr}h ago`;
47
+ const d = Math.floor(hr / 24);
48
+ return `${d}d ago`;
49
+ }
50
+
51
+ function ageHours(iso) {
52
+ if (!iso) return Infinity;
53
+ return (Date.now() - new Date(iso).getTime()) / (60 * 60 * 1000);
54
+ }
55
+
56
+ // --no-color / NO_COLOR support per https://no-color.org
57
+ const noColor = process.argv.includes("--no-color") || process.env.NO_COLOR;
58
+ function c(color, str) {
59
+ if (noColor) return str;
60
+ return `${color}${str}${C.reset}`;
61
+ }
62
+
63
+ let state;
64
+ try {
65
+ state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
66
+ } catch {
67
+ // No scan yet — render an onboarding hint so first-time users learn the
68
+ // command. Use the dim color so it doesn't dominate the status line.
69
+ process.stdout.write(
70
+ c(C.dim, "◆ cipherwake · no scan yet · ") + c(C.cyan, "npx pqcheck <domain> --ai")
71
+ );
72
+ process.exit(0);
73
+ }
74
+
75
+ const { domain, score, grade, ship_decision, written_at, max_severity, kind } = state;
76
+ const age = ageHours(written_at);
77
+
78
+ if (age > STALE_THRESHOLD_HOURS) {
79
+ const stalePart = c(C.dim, `◆ ${domain || "cipherwake"} · stale (${formatAge(written_at)})`);
80
+ const hintPart = c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`);
81
+ process.stdout.write(`${stalePart} · ${hintPart}`);
82
+ process.exit(0);
83
+ }
84
+
85
+ const symbolByDecision = { pass: "✓", review: "⚠", block: "✗" };
86
+ const colorByDecision = { pass: C.green, review: C.yellow, block: C.red };
87
+ const symbol = symbolByDecision[ship_decision] || "·";
88
+ const cdec = colorByDecision[ship_decision] || C.dim;
89
+
90
+ const dbrStr = typeof score === "number" ? `DBR ${score.toFixed(1)}` : "";
91
+ const gradeStr = grade ? grade : "";
92
+ const sevStr = max_severity && max_severity !== "none"
93
+ ? `· ${String(max_severity).toUpperCase()}`
94
+ : "";
95
+ const kindStr = kind && kind !== "scan" ? `· ${kind}` : "";
96
+
97
+ // Final layout (color-coded; ANSI stripped under --no-color / NO_COLOR=1):
98
+ // ◆ <domain> ✓|⚠|✗ ship_decision · DBR X.X grade · SEVERITY · kind · age
99
+ process.stdout.write(
100
+ c(cdec, "◆") +
101
+ " " +
102
+ c(C.bold, domain || "cipherwake") +
103
+ " " +
104
+ c(cdec, `${symbol} ${(ship_decision || "—").toUpperCase()}`) +
105
+ " " +
106
+ c(C.dim, `· ${dbrStr}${gradeStr ? " " + gradeStr : ""} ${sevStr} ${kindStr} · ${formatAge(written_at)}`)
107
+ );