pqcheck 0.15.1 → 0.15.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.
- package/bin/cipherwake-statusline.js +48 -20
- package/bin/pqcheck.js +162 -50
- package/package.json +1 -1
|
@@ -64,44 +64,72 @@ let state;
|
|
|
64
64
|
try {
|
|
65
65
|
state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
66
66
|
} catch {
|
|
67
|
-
// No scan yet —
|
|
68
|
-
//
|
|
67
|
+
// No scan yet — anchor on the brand so first-time users see Cipherwake
|
|
68
|
+
// every turn (and learn what the status line refers to), then nudge to
|
|
69
|
+
// the command. Dim so it doesn't dominate.
|
|
69
70
|
process.stdout.write(
|
|
70
|
-
c(C.dim, "◆
|
|
71
|
+
c(C.dim, "◆ Cipherwake · no scan yet — ") + c(C.cyan, "npx pqcheck <domain> --ai")
|
|
71
72
|
);
|
|
72
73
|
process.exit(0);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const { domain, score, grade, ship_decision, written_at, max_severity,
|
|
76
|
+
const { domain, score, grade, ship_decision, written_at, max_severity, unreachable } = state;
|
|
76
77
|
const age = ageHours(written_at);
|
|
77
78
|
|
|
78
79
|
if (age > STALE_THRESHOLD_HOURS) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Stale = the cached check is too old to anchor a deploy on. Use ◌
|
|
81
|
+
// (dotted circle) to signal "needs refresh" without alarming. Stays
|
|
82
|
+
// muted so an old check doesn't pretend to be active state.
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
c(C.dim, `◆ Cipherwake · ${domain || "—"} ◌ STALE · last checked ${formatAge(written_at)} — `) +
|
|
85
|
+
c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`)
|
|
86
|
+
);
|
|
82
87
|
process.exit(0);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
// Brand-anchored layout — "Cipherwake" is always the first word after the
|
|
91
|
+
// diamond, so the customer (and their AI agent) can identify the status
|
|
92
|
+
// line's source at a glance. Trailing segments depend on what we have:
|
|
93
|
+
//
|
|
94
|
+
// pass (no DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · just now
|
|
95
|
+
// pass (w/ DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · DBR 8.7 A · just now
|
|
96
|
+
// review: ◆ Cipherwake · pinnedai.dev ⚠ REVIEW · DBR 4.1 C · HIGH · 1h ago
|
|
97
|
+
// block: ◆ Cipherwake · pinnedai.dev ⛔ BLOCK · HIGH · now
|
|
98
|
+
// unreachable: ◆ Cipherwake · pinnedai.dev ⊘ UNREACHABLE · now
|
|
99
|
+
//
|
|
100
|
+
// "Unreachable" is a distinct visual + label even though ship_decision=block,
|
|
101
|
+
// because the failure mode is different (we couldn't measure the trust
|
|
102
|
+
// surface at all, vs. we found a critical finding). The AI agent still
|
|
103
|
+
// halts on block-routing — same protocol behavior, clearer customer
|
|
104
|
+
// messaging.
|
|
105
|
+
const isUnreachable = !!unreachable;
|
|
106
|
+
const symbolByDecision = { pass: "✓", review: "⚠", block: "⛔" };
|
|
86
107
|
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;
|
|
108
|
+
const symbol = isUnreachable ? "⊘" : (symbolByDecision[ship_decision] || "·");
|
|
109
|
+
const cdec = isUnreachable ? C.red : (colorByDecision[ship_decision] || C.dim);
|
|
110
|
+
const labelWord = isUnreachable
|
|
111
|
+
? "UNREACHABLE"
|
|
112
|
+
: (ship_decision || "—").toUpperCase();
|
|
89
113
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
114
|
+
// When unreachable, suppress DBR/severity trailing segments — they aren't
|
|
115
|
+
// meaningful (no score was computed) and would clutter the line. Just the
|
|
116
|
+
// glyph + UNREACHABLE label + age is enough.
|
|
117
|
+
const dbrSegment = (!isUnreachable && typeof score === "number")
|
|
118
|
+
? ` · DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`
|
|
119
|
+
: "";
|
|
120
|
+
const sevSegment = (!isUnreachable && max_severity && max_severity !== "none")
|
|
121
|
+
? ` · ${String(max_severity).toUpperCase()}`
|
|
94
122
|
: "";
|
|
95
|
-
const kindStr = kind && kind !== "scan" ? `· ${kind}` : "";
|
|
96
123
|
|
|
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
124
|
process.stdout.write(
|
|
100
125
|
c(cdec, "◆") +
|
|
101
126
|
" " +
|
|
102
|
-
c(C.bold,
|
|
127
|
+
c(C.bold, "Cipherwake") +
|
|
128
|
+
" " +
|
|
129
|
+
c(C.dim, "·") +
|
|
103
130
|
" " +
|
|
104
|
-
c(
|
|
131
|
+
c(C.bold, domain || "—") +
|
|
105
132
|
" " +
|
|
106
|
-
c(
|
|
133
|
+
c(cdec, `${symbol} ${labelWord}`) +
|
|
134
|
+
c(C.dim, `${dbrSegment}${sevSegment} · ${formatAge(written_at)}`)
|
|
107
135
|
);
|
package/bin/pqcheck.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
})();
|
|
25
25
|
|
|
26
26
|
const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
|
|
27
|
-
const VERSION = "0.15.
|
|
27
|
+
const VERSION = "0.15.2";
|
|
28
28
|
|
|
29
29
|
// API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
|
|
30
30
|
// per-account monthly quotas instead of the per-IP rate limit. Set via:
|
|
@@ -296,26 +296,78 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
296
296
|
console.error(`pqcheck: ⚠ ${domain} — using cached score (live probe failed: ${report._meta.degradedReason || "unknown"}; last verified ${report._meta.lastUpdated || "?"})`);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// Compute ship-decision once — needed both for AI-mode footer AND for the
|
|
300
|
+
// per-user/per-repo state files that statusline/chat-hook/prompt-hook read.
|
|
301
|
+
// State files must update on every successful scan, regardless of --ai, so
|
|
302
|
+
// downstream agents see the same scan the human just ran.
|
|
303
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
304
|
+
const maxSev = highestSeverity(findings);
|
|
305
|
+
let shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
306
|
+
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
307
|
+
|
|
308
|
+
// Unreachable / degraded → force "block". A scan that couldn't actually
|
|
309
|
+
// reach the domain (no DNS, TLS handshake failed, deploy hadn't propagated
|
|
310
|
+
// yet, typo in the domain) is categorically different from "trust drift
|
|
311
|
+
// detected" — we couldn't evaluate the trust surface AT ALL. "review"
|
|
312
|
+
// would be too soft (the AI agent might shrug and ship); "block" properly
|
|
313
|
+
// halts announcement per the AI Coder Protocol until the human confirms
|
|
314
|
+
// the unreachability was expected (e.g., they know DNS is still
|
|
315
|
+
// propagating) or fixes the deploy. This matches the protocol's "STOP,
|
|
316
|
+
// wait for explicit override" routing.
|
|
317
|
+
// Only treat literal "reachable === false" as unreachable. _meta.degraded
|
|
318
|
+
// is too broad — it also fires for fingerprint disagreement / cache
|
|
319
|
+
// fallback / mid-scan state changes on reachable domains (e.g. stripe.com
|
|
320
|
+
// mid-deploy), and those scans returned a real score we shouldn't hide
|
|
321
|
+
// behind an UNREACHABLE label.
|
|
322
|
+
const unreachable = report?.reachable === false;
|
|
323
|
+
if (unreachable) {
|
|
324
|
+
shipDecision = "block";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Capture reachability AFTER ship_decision override so state files reflect
|
|
328
|
+
// the truth: ship_decision=block + unreachable=true means "we couldn't
|
|
329
|
+
// reach it, treat as block." Downstream surfaces (statusline / VS Code
|
|
330
|
+
// extension / AI banner) display the "UNREACHABLE" label when
|
|
331
|
+
// unreachable=true instead of the generic "BLOCK" — more informative,
|
|
332
|
+
// same protocol routing.
|
|
333
|
+
await writeLastScanFile({
|
|
334
|
+
domain,
|
|
335
|
+
kind: "scan",
|
|
336
|
+
score: typeof report.score === "number" ? report.score : null,
|
|
337
|
+
grade: report.grade || null,
|
|
338
|
+
max_severity: maxSev,
|
|
339
|
+
ship_decision: shipDecision,
|
|
340
|
+
unreachable: unreachable || false,
|
|
341
|
+
top_issue: topFinding?.id || topFinding?.title || null,
|
|
342
|
+
});
|
|
343
|
+
|
|
299
344
|
// AI Coder Mode — three-layer output for Claude Code / Cursor / Aider.
|
|
300
345
|
// Overrides --format when --ai is set; emits the structured block at the
|
|
301
346
|
// bottom so downstream agents can parse ship_decision deterministically.
|
|
302
347
|
if (aiMode) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
348
|
+
let topIssue, whyMatters, nextActions;
|
|
349
|
+
if (unreachable) {
|
|
350
|
+
topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
|
|
351
|
+
whyMatters = report?._meta?.degradedReason || "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.";
|
|
352
|
+
nextActions = [
|
|
353
|
+
`Check the domain is correct (typo?): ${domain}`,
|
|
354
|
+
`Verify DNS: dig +short ${domain}`,
|
|
355
|
+
`Verify deploy completed and TLS is live on https://${domain}`,
|
|
356
|
+
`Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
|
|
357
|
+
];
|
|
358
|
+
} else {
|
|
359
|
+
topIssue = topFinding
|
|
360
|
+
? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
|
|
361
|
+
: "No findings at or above LOW severity.";
|
|
362
|
+
whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
|
|
363
|
+
nextActions = shipDecision === "pass"
|
|
364
|
+
? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
|
|
365
|
+
: [
|
|
366
|
+
`Review finding above and decide if it was intentional.`,
|
|
367
|
+
`View full report: ${API_BASE}/r/${domain}`,
|
|
368
|
+
`Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
|
|
369
|
+
];
|
|
370
|
+
}
|
|
319
371
|
|
|
320
372
|
console.log("");
|
|
321
373
|
console.log(formatAiBanner({
|
|
@@ -325,6 +377,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
325
377
|
grade: report.grade,
|
|
326
378
|
maxSeverity: maxSev,
|
|
327
379
|
shipDecision,
|
|
380
|
+
unreachable,
|
|
328
381
|
}));
|
|
329
382
|
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
330
383
|
console.log(formatAiFooterBlock({
|
|
@@ -343,16 +396,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
343
396
|
}));
|
|
344
397
|
console.log("");
|
|
345
398
|
|
|
346
|
-
await writeLastScanFile({
|
|
347
|
-
domain,
|
|
348
|
-
kind: "scan",
|
|
349
|
-
score: typeof report.score === "number" ? report.score : null,
|
|
350
|
-
grade: report.grade || null,
|
|
351
|
-
max_severity: maxSev,
|
|
352
|
-
ship_decision: shipDecision,
|
|
353
|
-
top_issue: topFinding?.id || topFinding?.title || null,
|
|
354
|
-
});
|
|
355
|
-
|
|
356
399
|
// Threshold check still applies under --ai (script-pipeable). Otherwise
|
|
357
400
|
// exit code reflects ship_decision so the caller can route on it.
|
|
358
401
|
if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
|
|
@@ -585,14 +628,30 @@ function aiStatusEmoji(shipDecision) {
|
|
|
585
628
|
return "⚠";
|
|
586
629
|
}
|
|
587
630
|
|
|
588
|
-
function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision }) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
631
|
+
function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision, unreachable }) {
|
|
632
|
+
// When the scanner couldn't reach the domain we render "UNREACHABLE"
|
|
633
|
+
// (with a distinct ⊘ glyph) instead of the generic "BLOCK" — semantically
|
|
634
|
+
// clearer for the customer: their site isn't deployed / isn't responding,
|
|
635
|
+
// not that we found a critical security finding.
|
|
636
|
+
//
|
|
637
|
+
// Suppress DBR + severity trailing segments when unreachable: even if the
|
|
638
|
+
// API returned a stale cached score on a degraded scan, surfacing it next
|
|
639
|
+
// to "UNREACHABLE" reads as contradictory ("how can it score X if you
|
|
640
|
+
// couldn't reach it?"). The age + banner already convey the situation.
|
|
641
|
+
const isUnreachable = !!unreachable;
|
|
642
|
+
const emoji = isUnreachable ? "⊘" : aiStatusEmoji(shipDecision);
|
|
643
|
+
const statusWord = isUnreachable
|
|
644
|
+
? "UNREACHABLE"
|
|
645
|
+
: (({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW");
|
|
646
|
+
const c = aiBannerColor(isUnreachable ? "block" : shipDecision);
|
|
647
|
+
const dbrSegment = (!isUnreachable && typeof dbr === "number")
|
|
648
|
+
? ` · DBR ${dbr.toFixed(1)}${grade ? " " + grade : ""}`
|
|
649
|
+
: "";
|
|
650
|
+
const sevSegment = (!isUnreachable && maxSeverity && maxSeverity !== "none")
|
|
651
|
+
? ` · ${String(maxSeverity).toUpperCase()}`
|
|
652
|
+
: "";
|
|
653
|
+
const kindSegment = (kind && kind !== "scan") ? ` · ${kind}` : "";
|
|
654
|
+
return color(c, `◆ Cipherwake · ${domain} ${emoji} ${statusWord}${dbrSegment}${sevSegment}${kindSegment}`);
|
|
596
655
|
}
|
|
597
656
|
|
|
598
657
|
function formatAiBody({ topIssue, whyMatters, nextActions }) {
|
|
@@ -2333,19 +2392,49 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
2333
2392
|
const report = await resp.json();
|
|
2334
2393
|
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
2335
2394
|
const maxSev = highestSeverity(findings);
|
|
2336
|
-
|
|
2395
|
+
let shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
2337
2396
|
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2397
|
+
|
|
2398
|
+
// Unreachable / degraded → force "block" with an UNREACHABLE display label
|
|
2399
|
+
// downstream. See main scan path for the rationale: an undeployed-or-broken
|
|
2400
|
+
// domain can't be evaluated, so the AI agent must halt announcement and ask
|
|
2401
|
+
// the human (was this expected? did the deploy fail?). The `unreachable`
|
|
2402
|
+
// field travels in the state file + structured block so statusline /
|
|
2403
|
+
// VS Code ext / chat-hook can render "UNREACHABLE" instead of generic
|
|
2404
|
+
// "BLOCK" — semantically clearer for this specific failure mode.
|
|
2405
|
+
// Only treat literal "reachable === false" as unreachable. _meta.degraded
|
|
2406
|
+
// is too broad — it also fires for fingerprint disagreement / cache
|
|
2407
|
+
// fallback / mid-scan state changes on reachable domains (e.g. stripe.com
|
|
2408
|
+
// mid-deploy), and those scans returned a real score we shouldn't hide
|
|
2409
|
+
// behind an UNREACHABLE label.
|
|
2410
|
+
const unreachable = report?.reachable === false;
|
|
2411
|
+
if (unreachable) {
|
|
2412
|
+
shipDecision = "block";
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
let topIssue, whyMatters, nextActions;
|
|
2416
|
+
if (unreachable) {
|
|
2417
|
+
topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
|
|
2418
|
+
whyMatters = report?._meta?.degradedReason || "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.";
|
|
2419
|
+
nextActions = [
|
|
2420
|
+
`Check the domain is correct (typo?): ${domain}`,
|
|
2421
|
+
`Verify DNS: dig +short ${domain}`,
|
|
2422
|
+
`Verify deploy completed and TLS is live on https://${domain}`,
|
|
2423
|
+
`Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
|
|
2424
|
+
];
|
|
2425
|
+
} else {
|
|
2426
|
+
topIssue = topFinding
|
|
2427
|
+
? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
|
|
2428
|
+
: "No findings at or above LOW severity.";
|
|
2429
|
+
whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
|
|
2430
|
+
nextActions = shipDecision === "pass"
|
|
2431
|
+
? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
|
|
2432
|
+
: [
|
|
2433
|
+
`Review finding above and decide if it was intentional.`,
|
|
2434
|
+
`View full report: ${API_BASE}/r/${domain}`,
|
|
2435
|
+
`Subsequent deploy-checks will diff against this scan as baseline.`,
|
|
2436
|
+
];
|
|
2437
|
+
}
|
|
2349
2438
|
|
|
2350
2439
|
console.log("");
|
|
2351
2440
|
console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
|
|
@@ -2357,23 +2446,46 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
2357
2446
|
grade: report.grade,
|
|
2358
2447
|
maxSeverity: maxSev,
|
|
2359
2448
|
shipDecision,
|
|
2449
|
+
unreachable,
|
|
2360
2450
|
}));
|
|
2361
2451
|
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
2362
2452
|
console.log(formatAiFooterBlock({
|
|
2363
|
-
status: shipDecision
|
|
2453
|
+
status: shipDecision,
|
|
2364
2454
|
domain,
|
|
2365
2455
|
kind: "scan",
|
|
2366
2456
|
dbr: report.score,
|
|
2367
2457
|
grade: report.grade,
|
|
2368
2458
|
max_severity: maxSev,
|
|
2369
2459
|
ship_decision: shipDecision,
|
|
2370
|
-
|
|
2460
|
+
unreachable: unreachable ? "true" : "false",
|
|
2461
|
+
top_issue: unreachable
|
|
2462
|
+
? "findings.reachability.unreachable"
|
|
2463
|
+
: (topFinding ? `findings.${topFinding.id || "unknown"}` : "none"),
|
|
2371
2464
|
findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
|
|
2372
2465
|
findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
|
|
2373
2466
|
scanned_at: new Date().toISOString(),
|
|
2374
2467
|
advisory_only: true,
|
|
2375
|
-
note:
|
|
2468
|
+
note: unreachable
|
|
2469
|
+
? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
|
|
2470
|
+
: "first-deploy: no baseline yet, scored on current state",
|
|
2376
2471
|
}));
|
|
2472
|
+
|
|
2473
|
+
await writeLastScanFile({
|
|
2474
|
+
domain,
|
|
2475
|
+
kind: "scan",
|
|
2476
|
+
score: typeof report.score === "number" ? report.score : null,
|
|
2477
|
+
grade: report.grade || null,
|
|
2478
|
+
max_severity: maxSev,
|
|
2479
|
+
ship_decision: shipDecision,
|
|
2480
|
+
unreachable: unreachable || false,
|
|
2481
|
+
top_issue: unreachable
|
|
2482
|
+
? "findings.reachability.unreachable"
|
|
2483
|
+
: (topFinding?.id || topFinding?.title || null),
|
|
2484
|
+
note: unreachable
|
|
2485
|
+
? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
|
|
2486
|
+
: "first-deploy: no baseline yet, scored on current state",
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2377
2489
|
// Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
|
|
2378
2490
|
process.exit(shipDecision === "pass" ? 0 : 1);
|
|
2379
2491
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pqcheck",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Deploy gate for AI-coded web apps. `pqcheck deploy-check --ai` returns ship_decision=pass|review|block for Claude Code / Cursor / Copilot / Aider to gate deploys before they ship. Anonymous, no signup, free for first use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-coder",
|