Harden analysis gate and preflight evidence
Adds evidence inventories, repo-link validation, structured gate failure summaries, and Copilot-only publishability documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 7, 2026 at 10:23 UTC
7ad6e63eb6db441883f72aacef29cff2be9e153c
14 files changed
+460
-10
.squad/agents/bender/history.md
+11
@@ -50,3 +50,14 @@
50
- Recommended PRD path is hybrid staged fan-out/fan-in: establish validated artifact contracts first, then gate RSS matrix, GitHub query matrix, and analysis map/reduce on measured thresholds.
51
- Run 27030646485 also showed analysis, not crawling, is the critical-path risk: three Copilot attempts consumed ~28m41s, failed quality gates, GitHub Models had no `openai/gpt-4o` access, and the workflow shipped via no-AI fallback with ~112.9k estimated input tokens.
52
- Issue #249 implementation: weekly analysis now writes to `data/candidates/<week>/<run_id>/` first and emits a `publish_eligibility_v1` manifest before any `data/analyzed/<week>-summary.md` promotion; promotion must fail closed on no-AI, stale source evidence, missing checksums, or failed validation.
53
+- Analysis preflight now emits raw and prompt-visible repository evidence inventories with byte/token/checksum metadata; analysis gate rejects final repo links outside current raw evidence when inventory is available.
54
+
55
+## Issue #287 — Analysis Gate Preflight Hardening (2026-06-06T21:23:50.664Z)
56
+
57
+- ✅ COMPLETE: Implemented evidence inventories (repo, article, source ledgers with integrity checksums)
58
+- ✅ COMPLETE: Repository evidence validation with citation/link integrity
59
+- ✅ COMPLETE: Structured gate failure summaries with classification (contract, context, timeout, fallback, other)
60
+- ✅ COMPLETE: Documentation and test coverage
61
+- pytest: 673 passed, 2 subtests
62
+- PR #288 created; ready for merge after Fry validation
63
+- Orchestration log recorded at `.squad/orchestration-log/20260606T212350Z-bender.md`
.squad/agents/fry/history.md
+8
@@ -109,3 +109,11 @@
109
- Added deterministic no-network regression coverage around publish eligibility manifests and promotion guard behavior.
110
- Good canonical weekly summary/content must remain unchanged when candidates are failed, degraded, no-AI, stale, missing-manifest, or malformed-manifest.
111
- Safe rerun promotion is copy-stable and must not append or duplicate article body content; ineligible candidates should remain in staging with promotion diagnostics for debugging.
112
+
113
+## Issue #287 — Analysis Gate Preflight Hardening (2026-06-06T21:23:50.664Z)
114
+
115
+- ✅ APPROVED: Bender's evidence inventory and gate failure classification implementation
116
+- ✅ Validated: Evidence schema, citation link integrity, gate failure handling across all cases
117
+- ✅ No regressions: pytest full pass (673 passed, 2 subtests)
118
+- PR #288 validated and approved; ready for Coordinator merge workflow
119
+- Orchestration log recorded at `.squad/orchestration-log/20260606T212350Z-fry.md`
.squad/agents/leela/history.md
+9
@@ -46,3 +46,12 @@
46
- Decisions recorded in .squad/decisions.md under four entries (Bender, Farnsworth, Fry, Leela)
47
- 2026-06-05T17:42:56.819+00:00 matrix/map-reduce PRD note: treat crawl matrix and analysis decomposition as separate decisions; keep crawl fan-out measurement-gated, while map/reduce is a citation-preserving LLM context/quality experiment with mapper claim ledgers and reducer-owned editorial coherence.
48
- 2026-06-05T21:03:35.661+00:00 issue hierarchy refresh: updated #248-#259 and created #261 so safe weekly reruns preserve good AI-authored articles, treat no-AI fallback as non-publishable replacement by default, reuse successful same-day per-source evidence artifacts with provenance/freshness guards, and make signal-type claim-ledger map/reduce the selected dry-run analysis architecture.
49
+
50
+## Round 4 (2026-06-06T21:19:24Z)
51
+
52
+- Orchestration session synthesized next priority from team learnings and run 27055543722 diagnosis
53
+- Ranked recommendations: (1) analysis gate & contract validation [P0 blocking], (2) deterministic preflight compaction [P0 supporting], (3) reader-facing methodology & governance path [P1 strategic]
54
+- Merged 10 inbox decisions from Bender, Farnsworth, Fry into .squad/decisions.md; documented orchestration at .squad/orchestration-log/2026-06-06T21-19-24Z-leela.md
55
+- Run 27056632166 post-mortems confirmed safety gates work; no-AI blocking and preservation validated in production
56
+- P0 safety layer (#250/#251/#252/#253) must complete before #254 atomicity can start
57
+- Issue sequencing finalized: contract/preflight (Phase 1) → atomicity/quality gates (Phase 2) → docs/evidence (Phase 3) → map/reduce stays dry-run (Phase 4)
.squad/decisions.md
+208
@@ -1,3 +1,13 @@
1
+## User Directive: Write Issues Before Development [2026-06-06T21:23:50.664+00:00]
2
+
3
+**By:** jmservera (via Copilot)
4
+
5
+**What:** Respect the way we work: write issues for plans before jumping straight on to development.
6
+
7
+**Why:** User request — captured for team memory
8
+
9
+---
10
+
11
# Run 27055543722 — Analysis Failure Root Cause & Diagnosis [2026-06-06]
12
13
## Executive Summary
@@ -370,3 +380,201 @@ Added small deterministic promotion-guard helper and regression tests for publis
380
**Captured for team memory:** User request that prioritizes Copilot reliability and token lifecycle management over automatic fallback.
381
382
383
+
384
+---
385
+
386
+## Bender: Issue #250 — Preserve Good Analysis on Rerun Failure
387
+
388
+**Date:** 2026-06-06
389
+
390
+**Decision:** Weekly publish reruns fail closed by default. The analyze workflow now emits a rejected no-AI candidate and manifest instead of exiting before manifest creation, hydrates the currently published summary from `publish`, and lets `publish_manifest.py` decide `promote`, `preserve`, or `block`.
391
+
392
+**Rationale:** Downstream jobs and operators need a stable handoff contract. A failed/degraded/no-AI/stale rerun should leave the last good published weekly summary discoverable while preserving the rejected candidate artifacts for diagnosis.
393
+
394
+---
395
+
396
+## Bender: Issue #255 — Regression-Safe Gate Revision
397
+
398
+**Date:** 2026-06-06
399
+
400
+**Decision:**
401
+- Calibrated deterministic publish quality against the current known-good W22 and W23 summaries instead of relying on brittle exact wording checks.
402
+- Kept Copilot CLI on its platform default model operationally, while recording omitted model provenance as `copilot-default` so existing valid Copilot runs are not blocked as `unknown`.
403
+- Final promotion remains fail-closed: `validation.gate_report.passed` and all required structural/provenance/evidence/editorial gate families must be present and passing.
404
+- Rejected no-AI fallback candidates now still emit a manifest and gate report before the workflow exits, preserving diagnostics without promotion.
405
+
406
+---
407
+
408
+## User Directive: Review Copilot PR Comments
409
+
410
+**Date:** 2026-06-06T17:29:38.291+00:00
411
+**By:** jmservera (via Copilot)
412
+
413
+**Directive:** Review all Copilot Review PR comments and resolve them before considering PR work complete.
414
+
415
+**Reason:** User request — captured for team memory.
416
+
417
+---
418
+
419
+## Farnsworth: Issue #251 — No-AI Fallback Policy
420
+
421
+**Date:** 2026-06-06T08:48:43.587+00:00
422
+
423
+**Decision:** No-AI weekly fallback output is diagnostic by default and cannot replace an existing good AI-authored article unless an operator selects `force-replace` and supplies actor/reason audit metadata.
424
+
425
+**Implementation Note:** Explicit first publish of no-AI fallback uses `allow-no-ai-first-publish`, requires no existing good AI article, a passing analysis gate, source provenance, attempted AI paths, and quality_score >= 70.
426
+
427
+---
428
+
429
+## Farnsworth: Issue #255 — Publish Quality Gate
430
+
431
+**Date:** 2026-06-06T08:48:43.587+00:00
432
+
433
+**Decision:** Keep structural analysis validation deterministic, but make publication depend on a structured gate report with separate structural schema, AI provenance, evidence/citation, and editorial-quality gate families. The publish manifest records those gate outcomes and promotion consumes them before replacing a published article.
434
+
435
+**Rationale:** This preserves existing schema repair behavior while preventing structurally valid but low-quality, stale-evidence-backed, contradictory, or no-AI fallback summaries from becoming publishable artifacts.
436
+
437
+---
438
+
439
+## Fry: QA Audit Decision Memo — Run #27056632166
440
+
441
+**Date:** 2026-06-06T08:38:45.537+00:00
442
+**Context:** Run 27056632166 succeeded end-to-end after PRs #267, #268, #271, #272 merged.
443
+
444
+### Immediate Actions — Close These Issues
445
+- **#251** (Block no-AI fallback from replacing AI-authored summaries)
446
+ - ✅ Promotion guard rejects no-ai source; manifest marks no-ai ineligible
447
+ - ✅ Run 27056632166 produced no-ai but did NOT promote (guard worked)
448
+ - **Verdict:** CLOSE — blocking confirmed in production
449
+
450
+- **#250** (Preserve existing good weekly analysis on failed/degraded reruns)
451
+ - ✅ Promotion guard rejects degraded/failed candidates
452
+ - ✅ Workflow copies only eligible week; does not overwrite prior
453
+ - ✅ Test `test_same_successful_rerun_is_stable_and_does_not_duplicate_content` passing
454
+ - **Verdict:** CLOSE — preservation confirmed in production
455
+
456
+### Defer These Issues (Design-Complete, Waiting for Upstream Work)
457
+- **#258** (Add map/reduce dry-run) — Deliberately deferring until #255/#257 (editorial/evidence gates) prove reliable in production. Timeline: 2-3 successful weeks with full gates, then begin map/reduce sidecar.
458
+- **#256** (Add preflight compaction and fallback policy) — Design complete (decisions.md documented); needs to run after this week's analysis completes.
459
+
460
+### Immediate PRs Needed (Next Week)
461
+1. **#255:** Implement editorial_quality_gate and evidence_freshness_gate (manifests and guards check for these fields but they're never populated)
462
+2. **#261:** Implement same-day source artifact reuse in crawl (guard exists but feature missing)
463
+3. **#259:** Create operator guide and safe rerun playbook
464
+4. **#273:** Document model routing policy (decision memo only)
465
+
466
+### Test Results & Validation
467
+- **Test Suite:** 562/563 passing (1 fixture data update needed)
468
+- **Safety Features Verified in Production:**
469
+ - No-AI blocking: ✅ Works
470
+ - Preservation: ✅ Works
471
+ - Publish sync: ✅ Works
472
+ - Manifest guards: ✅ Work
473
+
474
+### Recommendation
475
+✅ **Close #250 and #251** — safety verdicts confirmed in production.
476
+
477
+⚡ **Prioritize next-week PRs:** #255 (editorial/evidence gates) > #261 (same-day reuse) > #259 (operator docs) > #273 (model routing).
478
+
479
+---
480
+
481
+## Fry: Review Approval — Issue #250
482
+
483
+**Date:** 2026-06-06T08:48:43.587+00:00
484
+**Verdict:** APPROVE
485
+
486
+**Reviewed Commit:** `3f78be6632eea9ffeeb99c5a1eed1efad4af1648` on branch `squad/250-preserve-good-analysis`.
487
+
488
+**Rationale:**
489
+- Existing good weekly summaries are detected through markdown metadata plus prior candidate manifest provenance when available.
490
+- Failed validation, no-AI, stale evidence, and lower-quality candidates become ineligible and choose `promotion.decision: preserve` when a good published summary exists.
491
+- Preserve manifests record both `preserved_summary_path` and `rejected_candidate_path`; rejected candidate artifacts remain under `data/candidates/YYYY-WNN/<run-id>/`.
492
+
493
+**Tests Run:** 17 passed. Custom validation-failure preservation smoke test passed. Full suite: 602 passed, 1 unrelated existing failure.
494
+
495
+**Non-blocking Follow-up:** Refresh stale prediction fixture/test expectation so full suite is green again.
496
+
497
+---
498
+
499
+## Fry: Review Approval — Issue #251
500
+
501
+**Date:** 2026-06-06T08:48:43.587+00:00
502
+**Verdict:** APPROVE
503
+
504
+**Commit Reviewed:** 18e521e
505
+
506
+**Rationale:**
507
+- No-AI fallback candidates are blocked from default promotion, including over existing good AI-authored summaries.
508
+- No-existing-article behavior stays fail-closed by default and only permits first publish with explicit `allow-no-ai-first-publish` plus quality gates.
509
+- Force replacement requires explicit `force-replace` mode, actor, reason, fallback provenance, attempted AI paths, and emits manifest/audit data.
510
+
511
+**Validation:** `test_publish_manifest.py`, `test_promotion_guard.py`, and `test_pipeline.py` passed. Full suite: 606 passed, 1 failed (known unrelated fixture drift).
512
+
513
+**Non-blocking Follow-up:** Consider adding documented workflow-dispatch inputs for force replacement if operators are expected to use the escape hatch through GitHub Actions.
514
+
515
+---
516
+
517
+## Issue #255 — Manifest/Promotion Compatibility Decision Note
518
+
519
+**Date:** 2026-06-06
520
+
521
+**Context:** Leela rejected the prior #255 revision because `publish_manifest.py create/assert-eligible` could approve `data/candidates/` manifests with `generated_at` and nested `candidate.summary_path`, while `promotion_guard.py` still required `data/staging/`, `run_started_at`, and `candidate_content_path`.
522
+
523
+**Decision:**
524
+- Keep the current candidate manifest workflow intact and make the contract explicitly compatible in both directions.
525
+- `publish_manifest.py` emits compatibility aliases alongside the existing nested manifest fields.
526
+- `promotion_guard.py` accepts both `data/staging/` and `data/candidates/` manifest roots.
527
+- `promotion_guard.py` normalizes `generated_at` as a run timestamp fallback and uses the candidate summary as the content fallback for legacy summary-only manifests.
528
+- Gate-family checks remain fail-closed, and no-AI/degraded/rejected candidates remain blocked.
529
+
530
+**Verification:** Added regression tests proving an eligible manifest created and accepted by `publish_manifest assert-eligible` is accepted by `promotion_guard.promote_candidate()`, while a rejected/no-AI manifest is rejected by both without replacing existing published artifacts. Full suite passed.
531
+
532
+---
533
+
534
+## Leela: Issue Triage After Successful Run #27056632166
535
+
536
+**Date:** 2026-06-06T08:38:45.537+00:00
537
+
538
+**Context:** Run #27056632166 succeeded end-to-end; PRs #267, #268, #271, #272 merged (prediction schema repair, Copilot-only analysis fix, data sync).
539
+
540
+### P0 Critical Safety Layer Issues (All Remain Open)
541
+- **#250** (Preserve good weekly on failed rerun) — Blocking #254, needs implementation
542
+- **#251** (Block no-AI fallback) — Blocking #254, needs implementation
543
+- **#252** (Explicit safe rerun modes) — Blocking #254/#259, needs implementation
544
+- **#253** (Immutable backups) — Blocking #254, needs implementation
545
+- **#254** (Atomic weekly promotion) — Blocked by above four, cannot start yet
546
+
547
+### P0 Quality & Preflight Gates
548
+- **#255** (Strengthen publish gate) — Supports #254, not blocking, needs implementation
549
+- **#256** (Preflight compaction) — Feeds #258 map/reduce, P1 priority
550
+
551
+### P2 Analysis Architecture (Dry-Run Hold)
552
+- **#258** (Map/reduce dry-run) — **REMAINS DRY-RUN ONLY** until P0 safety layer complete. Do not promote until #250/#251/#252/#253/#254/#255/#256/#257 gates pass.
553
+
554
+### P1 Documentation & Evidence Tasks
555
+- **#259** (Document safe rerun/restore) — Depends on #252/#261 implementation, deferred
556
+- **#261** (Reuse same-day artifacts) — Supports #259/#258 evidence freshness
557
+- **#273** (Model routing policy) — Research complete, writeup needed, can start immediately
558
+
559
+### Recommended Work Order
560
+
561
+**Phase 1 (Next): P0 Safety Gates (Parallel Track)**
562
+1. #250, #251, #252, #253 — Implement in parallel; target all 4 complete within same sprint
563
+
564
+**Phase 2: Atomicity & Quality Gates**
565
+1. #254 — Start after Phase 1 complete
566
+2. #255, #256 — In parallel with #254
567
+
568
+**Phase 3: Documentation & Evidence Freshness**
569
+1. #259 — After #252 implementation lands
570
+2. #261 — Parallel or after crawl metrics complete
571
+3. #273 — No blocking dependencies; start anytime
572
+
573
+**Phase 4: Analysis Innovation (Deferred)**
574
+- #258 — Remains dry-run/tests-only until Phase 1-2 complete
575
+
576
+### Key Sequencing Constraints
577
+- ⛔ **#254 cannot start** until all of (#250, #251, #252, #253) are implemented and reviewed
578
+- ⛔ **#258 map/reduce must stay dry-run** until P0 safety layer is solid
579
+- ✓ **#273 (model routing)** has zero blocking dependencies and can start today
580
+
.squad/log/20260606T212350Z-issue-287-analysis-hardening.md
new
+14
@@ -0,0 +1,14 @@
1
+# Session Log — 2026-06-06T21:23:50.664Z — Issue #287 Analysis Hardening
2
+
3
+## Context
4
+Team completed P0 analysis gate and preflight hardening work (issue #287) after Bender's implementation and Fry's validation.
5
+
6
+## Session Work
7
+- Pre-check: decisions.md at 31,654 bytes; 1 inbox file
8
+- Archive gate: No entries > 30 days old (only 2026-06-06 entry exists)
9
+- Merged inbox directive: "Write issues for plans before development"
10
+- Created orchestration logs for Bender and Fry
11
+- Updated team memory with user directive
12
+
13
+## Status
14
+✅ Documentation workflow synchronized. Ready for next sprint cycle.
.squad/orchestration-log/20260606T212350Z-bender.md
new
+21
@@ -0,0 +1,21 @@
1
+# Bender Orchestration Log — 2026-06-06T21:23:50.664Z
2
+
3
+## Task
4
+Implement P0 analysis gate/preflight hardening for issue #287
5
+
6
+## Outcome
7
+✅ COMPLETE
8
+
9
+- Implemented evidence inventories with repo/article/source ledgers
10
+- Added repository evidence validation with citation/link integrity checks
11
+- Structured gate failure summaries with classification (contract, context, timeout, fallback, other)
12
+- Added comprehensive documentation in `docs/analysis-gate-preflight.md`
13
+- Full pytest passed (673 passed, 2 subtests)
14
+
15
+## Artifacts
16
+- Issue #287 created and tracked
17
+- PR #288 opened on branch `squad/287-analysis-gate-preflight-hardening`
18
+- Changes committed with full test validation
19
+
20
+## Notes
21
+Ready for Fry review and merge.
.squad/orchestration-log/20260606T212350Z-fry.md
new
+22
@@ -0,0 +1,22 @@
1
+# Fry Orchestration Log — 2026-06-06T21:23:50.664Z
2
+
3
+## Task
4
+Review and validate Bender's changes for issue #287
5
+
6
+## Outcome
7
+✅ APPROVED
8
+
9
+- Reviewed Bender's analysis gate/preflight hardening implementation
10
+- Verified evidence inventory structure and validation logic
11
+- Confirmed gate failure classification and structured reporting
12
+- Full pytest passed (673 passed, 2 subtests)
13
+- No regressions detected
14
+
15
+## Validation
16
+- Evidence inventory schemas validated
17
+- Citation link integrity checks tested
18
+- Gate failure handling verified across all case types
19
+- Documentation reviewed and complete
20
+
21
+## Notes
22
+PR #288 ready for merge after Coordinator integrates issue/branch/PR workflow.
docs/analysis-spec.md
+4
@@ -344,6 +344,10 @@ Publication also requires a structured gate report with four passing gate famili
344
345
The publish manifest records these gate outcomes and promotion must consume them before replacing a previously published AI-authored article.
346
347
+The preflight stage must also emit a deterministic analysis input manifest (`analysis-preflight.json`) before Copilot runs. That manifest records the rendered prompt byte/token estimate, prompt checksum, every prompt component with byte/token/checksum metadata, deterministic slice names, and evidence inventories for raw and prompt-visible repository sets. Each evidence inventory lists resolvable `owner/repo` names, source slice, GitHub URL when present, stars, and `stars_gained` when present. The gate must reject repository markdown links that do not resolve to the current raw evidence inventory when that inventory is available.
348
+
349
+Gate reports are machine-readable failure contracts. They include per-family `gates`, repair actions, `failure_class`, and a structured `failure_summary` with failed categories and error count so workflow retries and publish preservation can classify failures deterministically.
350
+
351
## Generator Handoff Rules
352
353
The generator may assume:
docs/operator-guide.md
+12
-3
@@ -502,7 +502,7 @@ data/candidates/YYYY-WNN/RUN_ID/
502
├── YYYY-WNN-content.md # Generated HTML candidate (if produced)
503
├── publish-manifest.json # Eligibility and provenance
504
└── diagnostics/
505
- ├── analysis-preflight.json # Pre-analysis context budget check
505
+ ├── analysis-preflight.json # Deterministic input manifest, context budget, evidence inventory
506
├── analysis-preflight.md # Preflight diagnostic report
507
├── copilot-cli-attempt-N.log # Raw Copilot CLI stderr/stdout
508
├── gate-copilot-cli-attempt-N.json # Quality gate failure details
@@ -520,7 +520,8 @@ cat data/candidates/2026-W21/RUN_ID/diagnostics/gate-copilot-cli-attempt-0.json
520
gates: .gates,
521
errors_before_repair: .errors_before_repair,
522
repair_actions: .repair_actions,
523
- failure_class: .failure_class
523
+ failure_class: .failure_class,
524
+ failure_summary: .failure_summary
525
}'
526
```
527
@@ -561,10 +562,18 @@ Gate report output:
562
"Added 3 spurious/false claims to Noise section"
563
],
564
"errors_after_repair": [],
564
- "failure_class": "passed"
565
+ "failure_class": "passed",
566
+ "failure_summary": {
567
+ "failure_class": "passed",
568
+ "failure_categories": [],
569
+ "error_count": 0,
570
+ "retryable": false
571
+ }
572
}
573
```
574
575
+`analysis-preflight.json` is the source of truth for prompt inputs. It includes byte/token/checksum metadata for each prompt component and evidence inventories (`raw_new_repos`, `raw_trending_repos`, `prompt_new_repos`, `prompt_trending_repos`) so operators can verify whether a final repo link was present in current crawl evidence or only in compacted prompt context.
576
+
577
### Quality gate specifics
578
579
The publish manifest records:
docs/pipeline-validation.md
+3
-1
@@ -65,8 +65,10 @@ Required secrets/tokens:
65
- Current raw file week matches the run week
66
- Correlation and press-context steps consume compact external-news data with legacy `YYYY-WNN-techcrunch.json` fallback
67
- Press context preserves source names, article URLs/titles/dates, strong-vs-weak labels, and partial-source caveats while staying under the ~8k token budget
68
+- Analysis preflight writes `analysis-preflight.json` with deterministic prompt component byte/token/checksum metadata and raw/prompt evidence inventories before Copilot is invoked.
69
- Copilot CLI output is staged under `data/candidates/YYYY-WNN/<run-id>/`; if Copilot cannot produce publishable analysis, a no-AI candidate is kept as a rejected artifact and the run fails closed before promotion.
69
-- `scripts/analysis_gate.py` passes before publish continues, and `scripts/publish_manifest.py` records promote-vs-preserve decisions against any existing good published summary.
70
+- `scripts/analysis_gate.py` passes before publish continues, rejects repo links outside the current raw evidence inventory where available, and emits structured failure summaries for deterministic retry/preserve decisions.
71
+- `scripts/publish_manifest.py` records promote-vs-preserve decisions against any existing good published summary.
72
- Job permissions include `actions: read`, `contents: write`, and `issues: write`
73
74
### 3. Generate
scripts/analysis_gate.py
+18
-2
@@ -43,7 +43,7 @@ REQUIRED_HEADINGS = [
43
"### Notable Projects",
44
"### Press & Industry",
45
]
46
-PUBLISHABLE_AI_SOURCES = {"copilot-cli", "github-models"}
46
+PUBLISHABLE_AI_SOURCES = {"copilot-cli"}
47
UNPUBLISHABLE_MODEL_VALUES = {"", "unknown", "unavailable", "none", "no-ai"}
48
RAW_MARKERS = [
49
"```json",
@@ -513,6 +513,11 @@ def evidence_citation_errors(body: str, raw_payload: dict[str, Any]) -> list[str
513
linked_repos = set(REPO_LINK_PATTERN.findall(body))
514
if repos and not linked_repos.intersection(repos):
515
errors.append("evidence citations must include at least one repository link from the raw payload.")
516
+ unresolved_links = sorted(linked_repos - repos) if repos else []
517
+ if unresolved_links:
518
+ preview = ", ".join(unresolved_links[:10])
519
+ suffix = f" (+{len(unresolved_links) - 10} more)" if len(unresolved_links) > 10 else ""
520
+ errors.append(f"repository links must resolve to the current raw evidence inventory: {preview}{suffix}.")
521
if repos and "## Key References" in body:
522
notable = section_text(body, "## Key References")
523
notable_links = set(REPO_LINK_PATTERN.findall(notable))
@@ -573,7 +578,7 @@ def ai_provenance_errors(source: str, model: str) -> list[str]:
578
def categorize_gate_error(error: str) -> str:
579
if error.startswith("AI provenance"):
580
return "ai_provenance"
576
- if error.startswith(("evidence citations", "Key References", "raw evidence")):
581
+ if error.startswith(("evidence citations", "Key References", "raw evidence", "repository links")):
582
return "evidence_citation"
583
if error.startswith(("editorial analysis", "contradictory claim")) or "section is too thin" in error or "must explain why" in error:
584
return "editorial_quality"
@@ -742,6 +747,7 @@ def write_gate_report(
747
"passed": not errors_after,
748
"word_count": word_count,
749
"gates": gate_results,
750
+ "failure_summary": build_failure_summary(errors_after, gate_results),
751
"errors_before_repair": errors_before,
752
"repair_actions": repair_actions,
753
"errors_after_repair": errors_after,
@@ -750,6 +756,16 @@ def write_gate_report(
756
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
757
758
759
+def build_failure_summary(errors: list[str], gate_results: dict[str, dict[str, Any]]) -> dict[str, Any]:
760
+ categories = sorted(category for category, result in gate_results.items() if not result.get("passed"))
761
+ return {
762
+ "failure_class": classify_gate_errors(errors),
763
+ "failure_categories": categories,
764
+ "error_count": len(errors),
765
+ "retryable": bool(errors) and not any(category == "ai_provenance" for category in categories),
766
+ }
767
+
768
+
769
def classify_gate_errors(errors: list[str]) -> str:
770
if not errors:
771
return "passed"
scripts/analyze_fallback.py
+69
@@ -51,6 +51,26 @@ class PromptComponent:
51
checksum_sha256: str
52
53
54
+@dataclass
55
+class EvidenceRepoRef:
56
+ full_name: str
57
+ url: str | None
58
+ source: str
59
+ stars: int | None
60
+ stars_gained: int | None
61
+
62
+
63
+@dataclass
64
+class EvidenceInventory:
65
+ name: str
66
+ path: str
67
+ item_count: int
68
+ bytes: int
69
+ token_estimate: int
70
+ checksum_sha256: str
71
+ repos: list[EvidenceRepoRef]
72
+
73
+
74
@dataclass
75
class PromptPreflight:
76
prompt_token_budget: int
@@ -65,6 +85,7 @@ class PromptPreflight:
85
fallback_policy: str
86
components: list[PromptComponent]
87
deterministic_slices: list[str]
88
+ evidence_inventories: list[EvidenceInventory]
89
90
91
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -165,6 +186,48 @@ def _component(
186
)
187
188
189
+def _repo_int(value: Any) -> int | None:
190
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
191
+
192
+
193
+def _inventory_repo_refs(payload: dict[str, Any], field: str) -> list[EvidenceRepoRef]:
194
+ repos = payload.get(field)
195
+ if not isinstance(repos, list):
196
+ return []
197
+ refs: list[EvidenceRepoRef] = []
198
+ for repo in repos:
199
+ if not isinstance(repo, dict):
200
+ continue
201
+ full_name = repo.get("full_name")
202
+ if not isinstance(full_name, str) or "/" not in full_name:
203
+ continue
204
+ url = repo.get("url")
205
+ refs.append(
206
+ EvidenceRepoRef(
207
+ full_name=full_name.strip(),
208
+ url=url if isinstance(url, str) and url.strip() else None,
209
+ source=field,
210
+ stars=_repo_int(repo.get("stars")),
211
+ stars_gained=_repo_int(repo.get("stars_gained")),
212
+ )
213
+ )
214
+ return refs
215
+
216
+
217
+def _evidence_inventory(name: str, payload: dict[str, Any], field: str, path: Path) -> EvidenceInventory:
218
+ content = json.dumps(payload.get(field, []), indent=2, ensure_ascii=False)
219
+ repos = _inventory_repo_refs(payload, field)
220
+ return EvidenceInventory(
221
+ name=name,
222
+ path=path.as_posix(),
223
+ item_count=len(repos),
224
+ bytes=len(content.encode("utf-8")),
225
+ token_estimate=estimate_tokens(content),
226
+ checksum_sha256=checksum_text(content),
227
+ repos=repos,
228
+ )
229
+
230
+
231
def truncate_with_notice(content: str, limit: int, label: str) -> tuple[str, str]:
232
if len(content) <= limit:
233
return content, "included"
@@ -473,6 +536,12 @@ def _build_prompt(
536
),
537
components=components,
538
deterministic_slices=["new_repos", "trending_repos", "press_correlations", "prior_continuity"],
539
+ evidence_inventories=[
540
+ _evidence_inventory("raw_new_repos", sanitized_payload, "new_repos", raw_json_path),
541
+ _evidence_inventory("raw_trending_repos", sanitized_payload, "trending_repos", raw_json_path),
542
+ _evidence_inventory("prompt_new_repos", payload_for_prompt, "new_repos", raw_json_path),
543
+ _evidence_inventory("prompt_trending_repos", payload_for_prompt, "trending_repos", raw_json_path),
544
+ ],
545
)
546
return prompt, preflight
547
tests/test_analysis_gate.py
+48
-4
@@ -12,7 +12,10 @@ RAW_PAYLOAD_WITH_REPOS = {
12
"week": "2026-W23",
13
"crawled_at": "2026-06-01T00:00:00Z",
14
"new_repos": [{"full_name": "owner/repo", "stars": 1000}],
15
- "trending_repos": [{"full_name": "owner/repo-b", "stars": 200}],
15
+ "trending_repos": [
16
+ {"full_name": "owner/repo-a", "stars": 300},
17
+ {"full_name": "owner/repo-b", "stars": 200},
18
+ ],
19
}
20
CURRENT_DATETIME = "2026-06-01T00:00:00Z"
21
@@ -227,8 +230,8 @@ summary: "A grounded week focused on practical tools."'''.strip()
230
self.assertEqual(errors, ["predictions[1].claim_type must be one of signal, noise, gap."])
231
self.assertIn("set date from current run timestamp", actions)
232
self.assertNotIn("claim_type", frontmatter_after["predictions"][0])
230
- self.assertEqual(frontmatter_after["repos_featured"], 2)
231
- self.assertEqual(frontmatter_after["stars_tracked"], 1200)
233
+ self.assertEqual(frontmatter_after["repos_featured"], 3)
234
+ self.assertEqual(frontmatter_after["stars_tracked"], 1500)
235
236
def test_repair_analysis_normalizes_safe_prediction_claim_alias(self) -> None:
237
frontmatter = VALID_FRONTMATTER + "\npredictions:\n - repo: owner/repo\n claim: Signal\n direction: UP\n confidence: 0.7"
@@ -445,6 +448,12 @@ No press data was provided this week.
448
"trending_repos": [],
449
}
450
text = summary_path.read_text(encoding="utf-8")
451
+ linked_repos = sorted(analysis_gate.REPO_LINK_PATTERN.findall(text))
452
+ raw_payload["new_repos"].extend(
453
+ {"full_name": name, "stars": 100}
454
+ for name in linked_repos
455
+ if name != repo_name
456
+ )
457
458
structure_errors, word_count = analysis_gate.validate_analysis(text, raw_payload, crawled_at)
459
publish_errors, gates = analysis_gate.validate_publish_quality(
@@ -482,7 +491,10 @@ No press data was provided this week.
491
self.assertTrue(gates["ai_provenance"]["passed"])
492
493
def test_publish_quality_gate_rejects_missing_evidence_citations(self) -> None:
485
- body = make_body().replace("[owner/repo-b](https://github.com/owner/repo-b)", "owner/repo-b")
494
+ body = make_body().replace("[owner/repo-a](https://github.com/owner/repo-a)", "owner/repo-a").replace(
495
+ "[owner/repo-b](https://github.com/owner/repo-b)",
496
+ "owner/repo-b",
497
+ )
498
errors, gates = analysis_gate.validate_publish_quality(
499
make_analysis(VALID_FRONTMATTER, body),
500
RAW_PAYLOAD_WITH_REPOS,
@@ -493,6 +505,18 @@ No press data was provided this week.
505
self.assertIn("evidence citations must include at least one repository link from the raw payload.", errors)
506
self.assertFalse(gates["evidence_citation"]["passed"])
507
508
+ def test_publish_quality_gate_rejects_repo_links_outside_current_inventory(self) -> None:
509
+ body = make_body().replace("[owner/repo-a](https://github.com/owner/repo-a)", "[other/repo](https://github.com/other/repo)")
510
+ errors, gates = analysis_gate.validate_publish_quality(
511
+ make_analysis(VALID_FRONTMATTER, body),
512
+ RAW_PAYLOAD_WITH_REPOS,
513
+ source="copilot-cli",
514
+ model="copilot-default",
515
+ )
516
+
517
+ self.assertIn("repository links must resolve to the current raw evidence inventory: other/repo.", errors)
518
+ self.assertFalse(gates["evidence_citation"]["passed"])
519
+
520
def test_publish_quality_gate_rejects_stale_evidence(self) -> None:
521
stale_payload = dict(RAW_PAYLOAD_WITH_REPOS, crawled_at="2026-05-25T00:00:00Z")
522
errors, gates = analysis_gate.validate_publish_quality(
@@ -533,6 +557,26 @@ No press data was provided this week.
557
self.assertIn("AI provenance model is not publishable: none.", errors)
558
self.assertFalse(gates["ai_provenance"]["passed"])
559
560
+ def test_publish_quality_gate_rejects_github_models_provenance(self) -> None:
561
+ errors, gates = analysis_gate.validate_publish_quality(
562
+ make_analysis(VALID_FRONTMATTER, make_body()),
563
+ RAW_PAYLOAD,
564
+ source="github-models",
565
+ model="openai/gpt-4o",
566
+ )
567
+
568
+ self.assertIn("AI provenance source is not publishable: github-models.", errors)
569
+ self.assertFalse(gates["ai_provenance"]["passed"])
570
+
571
+ def test_gate_report_includes_structured_failure_summary(self) -> None:
572
+ errors = ["repository links must resolve to the current raw evidence inventory: other/repo."]
573
+ gates = analysis_gate.build_gate_results(errors)
574
+ summary = analysis_gate.build_failure_summary(errors, gates)
575
+
576
+ self.assertEqual(summary["failure_class"], "evidence_citation")
577
+ self.assertEqual(summary["failure_categories"], ["evidence_citation"])
578
+ self.assertEqual(summary["error_count"], 1)
579
+
580
def test_publish_quality_gate_rejects_contradictory_press_claims(self) -> None:
581
body = make_body() + "\n\nNo press data was provided this week, but TechCrunch reported a major launch."
582
errors, gates = analysis_gate.validate_publish_quality(
tests/test_analyze_fallback.py
+13
@@ -237,6 +237,11 @@ class AnalyzeFallbackTests(unittest.TestCase):
237
components = {component["name"]: component for component in report["components"]}
238
self.assertEqual(components["new_repos"]["inclusion_reason"], "Deterministic mapper slice: newly discovered repositories.")
239
self.assertEqual(components["trending_repos"]["compaction_decision"], "included")
240
+ inventories = {inventory["name"]: inventory for inventory in report["evidence_inventories"]}
241
+ self.assertEqual(inventories["raw_new_repos"]["item_count"], 1)
242
+ self.assertEqual(inventories["raw_new_repos"]["repos"][0]["full_name"], "owner/new")
243
+ self.assertEqual(inventories["raw_trending_repos"]["repos"][0]["stars_gained"], 5)
244
+ self.assertGreater(inventories["prompt_new_repos"]["token_estimate"], 0)
245
246
def test_preflight_compacts_before_prompt_exceeds_budget(self) -> None:
247
tests_root = Path(__file__).resolve().parent
@@ -298,6 +303,14 @@ class AnalyzeFallbackTests(unittest.TestCase):
303
components = {component["name"]: component for component in report["components"]}
304
self.assertIn("compacted to top", components["new_repos"]["compaction_decision"])
305
self.assertIn("compacted to top", components["trending_repos"]["compaction_decision"])
306
+ inventories = {inventory["name"]: inventory for inventory in report["evidence_inventories"]}
307
+ self.assertEqual(inventories["raw_new_repos"]["item_count"], 60)
308
+ self.assertEqual(inventories["prompt_new_repos"]["item_count"], analyze_fallback.COMPACTED_NEW_REPOS_LIMIT)
309
+ self.assertEqual(inventories["raw_trending_repos"]["item_count"], 60)
310
+ self.assertEqual(
311
+ inventories["prompt_trending_repos"]["item_count"],
312
+ analyze_fallback.COMPACTED_TRENDING_REPOS_LIMIT,
313
+ )
314
315
def test_extract_markdown_supports_message_parts(self) -> None:
316
payload = {