File size: 25,369 Bytes
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f1532d
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
f74bce1
 
 
 
 
 
74ee21a
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
 
f74bce1
 
74ee21a
 
 
 
f74bce1
 
 
 
 
 
 
 
 
74ee21a
f74bce1
 
74ee21a
 
f74bce1
 
 
 
 
772a79f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
 
772a79f
 
 
 
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
74ee21a
 
 
 
 
f74bce1
 
 
 
 
 
772a79f
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772a79f
 
 
 
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772a79f
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
#!/usr/bin/env python3
"""Build a judge-facing award readiness scorecard for ProofFrame."""

from __future__ import annotations

import argparse
import importlib.util
import json
from pathlib import Path
from typing import Any

from proofframe.submission_gate import build_submission_gate


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_JSON = ROOT / "docs" / "assets" / "award-readiness-report.json"
DEFAULT_MD = ROOT / "docs" / "assets" / "award-readiness-report.md"


def load_script(module_name: str, relative_path: str):
    spec = importlib.util.spec_from_file_location(module_name, ROOT / relative_path)
    module = importlib.util.module_from_spec(spec)
    assert spec.loader is not None
    spec.loader.exec_module(module)
    return module


claim_lint = load_script("claim_lint", "scripts/claim_lint.py")
demo_readiness = load_script("demo_readiness", "scripts/demo_readiness.py")
secret_scan = load_script("secret_scan", "scripts/secret_scan.py")


def load_json(path: Path) -> dict[str, Any] | None:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (FileNotFoundError, json.JSONDecodeError):
        return None


def read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return ""


def task_statuses(root: Path) -> dict[str, str]:
    tasks = load_json(root / "tasks.json") or {}
    return {
        str(task.get("id", "")).upper(): str(task.get("status", "missing"))
        for task in tasks.get("tasks", [])
    }


def has_text(root: Path, relative_path: str, needle: str) -> bool:
    return needle in read_text(root / relative_path)


def has_any_text(root: Path, relative_path: str, needles: list[str]) -> bool:
    text = read_text(root / relative_path).lower()
    return any(needle.lower() in text for needle in needles)


def path_present(root: Path, relative_path: str) -> bool:
    return (root / relative_path).exists()


def run_secret_scan(root: Path) -> list[str]:
    original_root = secret_scan.ROOT
    secret_scan.ROOT = root
    try:
        return secret_scan.run_scan(root)
    finally:
        secret_scan.ROOT = original_root


def signal(signal_id: str, label: str, ok: bool, points: int, detail: str) -> dict[str, Any]:
    return {
        "id": signal_id,
        "label": label,
        "ok": ok,
        "points": points if ok else 0,
        "max_points": points,
        "detail": detail,
    }


def criterion(criterion_id: str, label: str, signals: list[dict[str, Any]]) -> dict[str, Any]:
    earned = sum(item["points"] for item in signals)
    maximum = sum(item["max_points"] for item in signals)
    return {
        "id": criterion_id,
        "label": label,
        "score": earned,
        "max_score": maximum,
        "percent": round(earned / maximum * 100, 1) if maximum else 0,
        "signals": signals,
    }


def b2_setup_ready(root: Path) -> bool:
    setup = load_json(root / "docs" / "assets" / "b2-live-setup.json") or {}
    bucket_name = setup.get("bucket_name") or setup.get("bucket")
    return bool(bucket_name and setup.get("endpoint") and setup.get("bucket_type") == "private")


def packet_has_public_demo(root: Path) -> bool:
    packet = load_json(root / "docs" / "assets" / "devpost-submission-packet.json") or {}
    return str(packet.get("demo_url", "")).endswith("/?judge=1")


def b2_evidence_ready(root: Path) -> bool:
    evidence = load_json(root / "docs" / "assets" / "b2-live-proof-evidence.json") or {}
    return bool(
        evidence.get("ok")
        and evidence.get("storage_backend") == "b2"
        and evidence.get("asset_storage_backend") == "b2"
        and evidence.get("asset_sha256")
        and evidence.get("manifest_sha256")
    )


def sponsor_fit_audit_ready(root: Path) -> bool:
    report = load_json(root / "docs" / "assets" / "sponsor-fit-audit.json") or {}
    return bool(report.get("ok") and report.get("mode") == "sponsor_fit_ready")


def event_snapshot_ready(root: Path) -> bool:
    report = load_json(root / "docs" / "assets" / "devpost-event-snapshot.json") or {}
    criteria = report.get("rules", {}).get("judging_criteria", [])
    requirements = report.get("rules", {}).get("requirements", {})
    return bool(
        report.get("schema") == "proofframe.devpost_event_snapshot.v1"
        and report.get("validation", {}).get("ok")
        and report.get("validation", {}).get("submission_open")
        and all(item.get("present") for item in criteria)
        and all(requirements.get(key) for key in ["working_app_url", "github_repo_url", "demo_video"])
    )


def build_criteria(root: Path, context: dict[str, Any]) -> list[dict[str, Any]]:
    statuses = context["task_statuses"]
    demo = context["demo_readiness"]
    claim = context["claim_lint"]
    secret_findings = context["secret_findings"]
    gate = context["submission_gate"]
    return [
        criterion(
            "sponsor_fit",
            "Sponsor integration fit",
            [
                signal(
                    "b2_backend_code",
                    "B2 storage backend is implemented",
                    has_text(root, "src/proofframe/storage.py", "class B2StorageBackend"),
                    5,
                    "Backblaze B2 has a dedicated S3-compatible storage adapter.",
                ),
                signal(
                    "b2_bucket_setup",
                    "Dedicated private B2 bucket is recorded",
                    b2_setup_ready(root),
                    4,
                    "The non-secret B2 bucket setup record exists for final proof.",
                ),
                signal(
                    "b2_live_runner",
                    "B2-only live proof runner exists",
                    path_present(root, "scripts/run_b2_live_proof.py"),
                    4,
                    "B2 storage can be verified independently before Genblaze is ready.",
                ),
                signal(
                    "genblaze_provider_code",
                    "Genblaze provider path is implemented",
                    has_text(root, "src/proofframe/providers.py", "class GenblazeMediaProvider"),
                    5,
                    "The app has Genblaze provider adapters for GMICloud, OpenAI, and credential-free local Pipeline proof.",
                ),
                signal(
                    "final_live_runner",
                    "Final B2 plus Genblaze runner exists",
                    path_present(root, "scripts/run_final_live_proof.py"),
                    4,
                    "A one-command runner can produce sanitized final evidence with the configured Genblaze provider.",
                ),
                signal(
                    "sponsor_fit_matrix",
                    "Sponsor fit matrix is documented",
                    path_present(root, "docs/sponsor_fit_matrix.md"),
                    4,
                    "Judging angles are mapped to current evidence, safe claims, final gates, and demo shots.",
                ),
                signal(
                    "sponsor_fit_audit",
                    "Sponsor fit audit is green",
                    sponsor_fit_audit_ready(root),
                    4,
                    "Devpost B2/Genblaze copy is specific and the demo introduces B2 early.",
                ),
                signal(
                    "b2_live_evidence",
                    "Live B2 evidence is captured",
                    statuses.get("T020") == "done" and b2_evidence_ready(root),
                    4,
                    "T020 requires a real B2 media and manifest proof.",
                ),
                signal(
                    "genblaze_live_evidence",
                    "Live Genblaze evidence is captured",
                    statuses.get("T021") == "done" and gate["evidence_gate"]["ok"],
                    4,
                    "T021 requires provider/model metadata from a live Genblaze run.",
                ),
            ],
        ),
        criterion(
            "product_depth",
            "Provenance product depth",
            [
                signal(
                    "manifest_model",
                    "Manifest model captures provenance",
                    has_text(root, "src/proofframe/models.py", "CampaignManifest"),
                    4,
                    "Assets, prompts, provider/model metadata, hashes, and review state are modeled.",
                ),
                signal(
                    "packet_zip",
                    "Evidence packet ZIP endpoint exists",
                    has_text(root, "src/proofframe/app.py", "packet.zip"),
                    4,
                    "Judges can inspect a packaged manifest and local media evidence.",
                ),
                signal(
                    "judge_packet",
                    "One-click judge packet exists",
                    has_text(root, "src/proofframe/app.py", "/api/demo/judge-packet"),
                    4,
                    "The demo can be loaded quickly into a believable reviewer workflow.",
                ),
                signal(
                    "review_console",
                    "Review console UI is present",
                    has_any_text(
                        root,
                        "apps/web/index.html",
                        ["review console", "review-console", 'aria-label="Review console"'],
                    ),
                    4,
                    "The app is positioned as an operations desk, not a generic generator.",
                ),
                signal(
                    "sponsor_model_ui",
                    "Sponsor evidence model is visible in the UI",
                    has_text(root, "apps/web/index.html", "Sponsor Evidence Model"),
                    3,
                    "Judge mode foregrounds Genblaze, B2 object route, manifest proof, and claim mode.",
                ),
                signal(
                    "prd_spec",
                    "PRD and technical spec are present",
                    path_present(root, "docs/prd.md") and path_present(root, "docs/spec.md"),
                    4,
                    "The product and implementation story are documented for judges and maintainers.",
                ),
            ],
        ),
        criterion(
            "demo_and_submission",
            "Demo and Devpost readiness",
            [
                signal(
                    "public_judge_demo",
                    "Public judge-mode demo URL is packaged",
                    packet_has_public_demo(root),
                    5,
                    "The Devpost packet points judges to a credential-free public demo.",
                ),
                signal(
                    "devpost_form_kit",
                    "Devpost form kit is generated",
                    path_present(root, "docs/assets/devpost-form-kit.json")
                    and path_present(root, "docs/assets/devpost-form-kit.md"),
                    4,
                    "Field-by-field copy is ready and length checked.",
                ),
                signal(
                    "official_event_snapshot",
                    "Official Devpost snapshot is fresh",
                    event_snapshot_ready(root),
                    4,
                    "Deadline, participants, requirements, and judging criteria are refreshed from Devpost.",
                ),
                signal(
                    "storyboard",
                    "Demo storyboard is machine-checked",
                    path_present(root, "docs/assets/demo-storyboard.json")
                    and path_present(root, "docs/assets/demo-storyboard.md"),
                    4,
                    "The video can be recorded around a clear judge story.",
                ),
                signal(
                    "mock_recording_ready",
                    "Mock recording package is ready",
                    bool(demo["mock_recording_ready"]),
                    4,
                    "The current public demo can be recorded safely before live proof.",
                ),
                signal(
                    "screenshots",
                    "Key screenshots are captured",
                    all(
                        path_present(root, path)
                        for path in [
                            "docs/assets/proofframe-local-ui-smoke.png",
                            "docs/assets/proofframe-review-console-smoke.png",
                            "docs/assets/proofframe-hf-public-smoke.png",
                        ]
                    ),
                    4,
                    "Local, review-console, and public demo screenshots are available.",
                ),
                signal(
                    "devpost_registered",
                    "Devpost registration is done",
                    statuses.get("T040") == "done",
                    4,
                    "The project is through the registration gate.",
                ),
            ],
        ),
        criterion(
            "trust_and_compliance",
            "Trust, safety, and claim discipline",
            [
                signal(
                    "secret_scan",
                    "Secret scan is clean",
                    not secret_findings,
                    4,
                    "No obvious API keys, cookies, signed URLs, or tokens were found in public files.",
                ),
                signal(
                    "claim_lint",
                    "Public claim lint is clean",
                    bool(claim["ok"]),
                    4,
                    "Public copy only claims B2 or Genblaze proof when sanitized evidence is present.",
                ),
                signal(
                    "env_ignored",
                    "Local final env file is ignored",
                    has_text(root, ".gitignore", ".env.final.local"),
                    3,
                    "Credential-bearing local env files stay out of Git.",
                ),
                signal(
                    "safe_evidence_writer",
                    "Evidence writer rejects secret-like values",
                    has_text(root, "scripts/api_smoke.py", "assert_safe_evidence"),
                    2,
                    "Live evidence cannot silently include key-like fields or signed URLs.",
                ),
                signal(
                    "claim_freeze_doc",
                    "Public claim freeze is documented",
                    path_present(root, "docs/public_claim_freeze.md"),
                    2,
                    "The team has a written boundary for what can be said before final proof.",
                ),
            ],
        ),
        criterion(
            "final_closure",
            "Final submission closure",
            [
                signal(
                    "final_gate",
                    "Final live proof gate is green",
                    bool(gate["ok"]),
                    6,
                    "Requires T020, T021, T040, T041, T041A, T042, final live evidence, and final reports.",
                ),
                signal(
                    "final_audit_done",
                    "Final audit task is done",
                    statuses.get("T041") == "done",
                    2,
                    "The reviewer audit must pass after live evidence exists.",
                ),
                signal(
                    "final_secret_scan_done",
                    "Final secret scan task is done",
                    statuses.get("T041A") == "done",
                    1,
                    "The final scan should run after live proof and final public submission artifacts are generated.",
                ),
                signal(
                    "devpost_submitted",
                    "Devpost project is submitted",
                    statuses.get("T042") == "done",
                    1,
                    "The submission is not complete until the project page is submitted.",
                ),
            ],
        ),
    ]


def build_next_actions(report: dict[str, Any]) -> list[str]:
    statuses = report["task_statuses"]
    actions: list[str] = []
    if statuses.get("T020") != "done":
        actions.append(
            "After explicit key-creation confirmation, create the scoped B2 key and run "
            "python scripts/run_b2_live_proof.py --env-file .env.final.local."
        )
    if statuses.get("T021") != "done":
        actions.append(
            "Run the credential-free local Genblaze Pipeline proof with python scripts/run_final_live_proof.py "
            "--env-file .env.final.local --genblaze-provider local --genblaze-image-model local-svg-v1."
        )
    if statuses.get("T041A") != "done":
        if statuses.get("T020") == "done" and statuses.get("T021") == "done":
            actions.append("Run python scripts/secret_scan.py after the public video and Devpost-safe artifacts are staged.")
        else:
            actions.append("Run python scripts/secret_scan.py after live evidence is generated.")
    if statuses.get("T041") != "done":
        actions.append(
            "Run python scripts/submission_audit.py --strict-final after live proof, public video URL, and T041A are done."
        )
    if statuses.get("T042") != "done":
        actions.append("Submit the Devpost project only after the final gate turns green.")
    return actions[:5]


def readiness_mode(score: int, gate_ok: bool, evidence_status: str | None = None) -> str:
    if gate_ok and score >= 90:
        return "final_award_ready"
    if evidence_status == "verified" and score >= 75:
        return "proof_verified_video_pending"
    if score >= 75:
        return "pre_live_competitive"
    return "needs_polish"


def readiness_interpretation(criteria: list[dict[str, Any]], gate_ok: bool, mode: str) -> dict[str, Any]:
    by_id = {item["id"]: item for item in criteria}
    final_closure = by_id.get("final_closure", {})
    demo_submission = by_id.get("demo_and_submission", {})
    final_closure_score = int(final_closure.get("score") or 0)
    final_closure_max = int(final_closure.get("max_score") or 0)
    return {
        "mode": mode,
        "pre_live_competitive": mode == "pre_live_competitive",
        "final_award_ready": mode == "final_award_ready",
        "submission_gate_ok": gate_ok,
        "demo_and_submission_score": demo_submission.get("score"),
        "demo_and_submission_max_score": demo_submission.get("max_score"),
        "final_closure_score": final_closure_score,
        "final_closure_max_score": final_closure_max,
        "boundary": (
            "Final award readiness is complete."
            if gate_ok
            else "B2 and Genblaze proof are verified; final award readiness still requires the public video, final audit, final secret scan, and Devpost receipt."
            if mode == "proof_verified_video_pending"
            else "Pre-live score reflects product, demo, and documentation strength; final award readiness still requires live proof, final audit, and Devpost receipt."
        ),
    }


def build_report(root: Path = ROOT) -> dict[str, Any]:
    root = root.resolve()
    context = {
        "task_statuses": task_statuses(root),
        "submission_gate": build_submission_gate(root),
        "demo_readiness": demo_readiness.build_report(root),
        "claim_lint": claim_lint.build_claim_report(root),
        "secret_findings": run_secret_scan(root),
    }
    criteria = build_criteria(root, context)
    score = sum(item["score"] for item in criteria)
    max_score = sum(item["max_score"] for item in criteria)
    mode = readiness_mode(
        score,
        context["submission_gate"]["ok"],
        context["submission_gate"]["evidence_gate"]["status"],
    )
    report = {
        "schema": "proofframe.award_readiness.v1",
        "project": "ProofFrame",
        "score": score,
        "max_score": max_score,
        "percent": round(score / max_score * 100, 1) if max_score else 0,
        "mode": mode,
        "public_demo_url": (
            load_json(root / "docs" / "assets" / "devpost-submission-packet.json") or {}
        ).get("demo_url"),
        "task_statuses": {
            task: context["task_statuses"].get(task, "missing")
            for task in ["T020", "T021", "T040", "T041", "T041A", "T042"]
        },
        "submission_gate": {
            "ok": context["submission_gate"]["ok"],
            "mode": context["submission_gate"]["mode"],
            "summary": context["submission_gate"]["summary"],
            "evidence_status": context["submission_gate"]["evidence_gate"]["status"],
            "report_status": context["submission_gate"]["report_gate"]["status"],
        },
        "demo_readiness": {
            "mode": context["demo_readiness"]["mode"],
            "mock_recording_ready": context["demo_readiness"]["mock_recording_ready"],
            "final_recording_ready": context["demo_readiness"]["final_recording_ready"],
        },
        "claim_lint": {
            "ok": context["claim_lint"]["ok"],
            "mode": context["claim_lint"]["mode"],
            "finding_count": len(context["claim_lint"]["findings"]),
        },
        "secret_scan": {
            "ok": not context["secret_findings"],
            "finding_count": len(context["secret_findings"]),
        },
        "criteria": criteria,
        "readiness_interpretation": readiness_interpretation(
            criteria,
            context["submission_gate"]["ok"],
            mode,
        ),
    }
    report["next_actions"] = build_next_actions(report)
    return report


def render_markdown(report: dict[str, Any]) -> str:
    lines = [
        "# ProofFrame Award Readiness",
        "",
        f"Mode: `{report['mode']}`",
        f"Score: `{report['score']}/{report['max_score']}` ({report['percent']}%)",
        f"Public demo: {report['public_demo_url']}",
        "",
        "## Gate Snapshot",
        "",
        f"- Submission gate: `{report['submission_gate']['mode']}`",
        f"- Live evidence: `{report['submission_gate']['evidence_status']}`",
        f"- Final reports: `{report['submission_gate']['report_status']}`",
        f"- Mock recording ready: `{str(report['demo_readiness']['mock_recording_ready']).lower()}`",
        f"- Final recording ready: `{str(report['demo_readiness']['final_recording_ready']).lower()}`",
        f"- Claim lint: `{str(report['claim_lint']['ok']).lower()}`",
        f"- Secret scan: `{str(report['secret_scan']['ok']).lower()}`",
        f"- Final closure: `{report['readiness_interpretation']['final_closure_score']}/{report['readiness_interpretation']['final_closure_max_score']}`",
        f"- Interpretation: {report['readiness_interpretation']['boundary']}",
        "",
        "## Criteria",
        "",
    ]
    for item in report["criteria"]:
        lines.append(
            f"### {item['label']} - {item['score']}/{item['max_score']} ({item['percent']}%)"
        )
        for sig in item["signals"]:
            marker = "OK" if sig["ok"] else "TODO"
            lines.append(
                f"- {marker} `{sig['id']}` ({sig['points']}/{sig['max_points']}): {sig['detail']}"
            )
        lines.append("")
    lines.extend(["## Next Actions", ""])
    if report["next_actions"]:
        lines.extend(f"- {action}" for action in report["next_actions"])
    else:
        lines.append("- Ready to submit and defend with verified live evidence.")
    return "\n".join(lines) + "\n"


def write_outputs(report: dict[str, Any], json_path: Path, markdown_path: Path) -> None:
    json_path.parent.mkdir(parents=True, exist_ok=True)
    markdown_path.parent.mkdir(parents=True, exist_ok=True)
    json_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    markdown_path.write_text(render_markdown(report), encoding="utf-8")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Build a ProofFrame award readiness scorecard.")
    parser.add_argument("--root", type=Path, default=ROOT)
    parser.add_argument("--json-out", type=Path, default=DEFAULT_JSON)
    parser.add_argument("--markdown-out", type=Path, default=DEFAULT_MD)
    parser.add_argument("--min-score", type=int, default=75)
    parser.add_argument(
        "--strict-final",
        action="store_true",
        help="Fail unless the final B2 plus Genblaze gate is award-ready.",
    )
    return parser


def main() -> None:
    args = build_parser().parse_args()
    report = build_report(args.root)
    write_outputs(report, args.json_out, args.markdown_out)
    ok = report["mode"] == "final_award_ready" if args.strict_final else report["score"] >= args.min_score
    print(
        json.dumps(
            {
                "ok": ok,
                "mode": report["mode"],
                "score": report["score"],
                "max_score": report["max_score"],
                "json": str(args.json_out),
                "markdown": str(args.markdown_out),
                "next_actions": report["next_actions"],
            },
            indent=2,
        )
    )
    raise SystemExit(0 if ok else 2)


if __name__ == "__main__":
    main()