Add deterministic analysis preflight and fallback policy (#281)

* analysis: add prompt preflight compaction Add deterministic rendered-prompt preflight reporting before Copilot analysis, including component checksums, token bounds, compaction decisions, and Copilot-only fallback classification. Keep no-AI diagnostic-only and remove GitHub Models model wiring from the workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: block degraded preflight promotion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: enforce copilot-only analysis promotion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 6, 2026 at 22:10 UTC ba6e192b291ce96bcf61cdcaa1f19cb758f06fb4
10 files changed +873 -120
.github/workflows/crawl-and-publish.yml
+54 -31
@@ -45,7 +45,6 @@ concurrency:
45
46 env:
47 PYTHONPATH: ${{ github.workspace }}
48 - GITHUB_MODELS_MODEL: ${{ vars.GITHUB_MODELS_MODEL || 'openai/gpt-4o' }}
48
49 jobs:
50 crawl:
@@ -436,27 +435,65 @@ jobs:
435 PY
436 echo "press_file=$PRESS_FILE" >> "$GITHUB_OUTPUT"
437
439 - - name: Pre-flight cost check
440 - id: preflight-cost
438 + - name: Render and preflight analysis prompt
439 + id: prompt-preflight
440 run: |
441 + set -euo pipefail
442 WEEK_FILE="${{ steps.analysis-context.outputs.week_file }}"
443 - CONTEXT_FILES=("$WEEK_FILE")
444 - if [ -f ".squad/skills/wisdom.md" ]; then
445 - CONTEXT_FILES+=(".squad/skills/wisdom.md")
446 - fi
447 - for f in .squad/skills/*.md; do
448 - [ -f "$f" ] && CONTEXT_FILES+=("$f")
449 - done
450 - # Use the generic Copilot default cost profile so the workflow never pins a stale CLI model.
443 + WEEK="${{ steps.analysis-context.outputs.week }}"
444 + OUTPUT_FILE="${{ steps.analysis-context.outputs.candidate_output_file }}"
445 + CURRENT_DATETIME="${{ steps.analysis-context.outputs.current_datetime }}"
446 + PRESS_FILE="${{ steps.press-context.outputs.press_file }}"
447 + DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
448 + PROMPT_FILE="data/metrics/analysis-prompt-${WEEK}.md"
449 + PREFLIGHT_JSON="$DIAGNOSTICS_DIR/analysis-preflight.json"
450 + PREFLIGHT_MD="$DIAGNOSTICS_DIR/analysis-preflight.md"
451 + mkdir -p data/metrics "$(dirname "$OUTPUT_FILE")" "$DIAGNOSTICS_DIR"
452 + # Hydrate metrics ledger from publish before writing this run's prompt/preflight artifacts.
453 + git fetch origin publish 2>/dev/null && \
454 + git checkout origin/publish -- data/metrics/ 2>/dev/null || true
455 + python3 scripts/analyze_fallback.py \
456 + --raw-json "$WEEK_FILE" \
457 + --output "$OUTPUT_FILE" \
458 + --current-datetime "$CURRENT_DATETIME" \
459 + --press-context "$PRESS_FILE" \
460 + --prompt-token-budget "${ANALYSIS_PROMPT_TOKEN_BUDGET:-90000}" \
461 + --preflight-report-json "$PREFLIGHT_JSON" \
462 + --preflight-report-md "$PREFLIGHT_MD" \
463 + --print-prompt > "$PROMPT_FILE"
464 python3 scripts/preflight_cost_check.py \
452 - --context-files "${CONTEXT_FILES[@]}"
465 + --context-files "$PROMPT_FILE"
466 + python3 - <<'PY' "$PREFLIGHT_JSON"
467 + import json
468 + import sys
469 + from pathlib import Path
470 +
471 + report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
472 + print(
473 + "::notice::Analysis preflight "
474 + f"tokens={report['prompt_tokens']}/{report['prompt_token_budget']} "
475 + f"bytes={report['prompt_bytes']} "
476 + f"degraded={report['degraded']} "
477 + f"publish_eligible={report['publish_eligible']} "
478 + f"promotion_policy={report.get('promotion_policy', 'unspecified')} "
479 + f"checksum={report['prompt_checksum_sha256'][:12]}"
480 + )
481 + if report.get("degraded") or not report.get("publish_eligible"):
482 + print(
483 + "::warning::Analysis preflight is degraded/compacted or publish-ineligible; "
484 + "output will remain staged/candidate-only unless an explicit promotion policy allows it."
485 + )
486 + PY
487 + echo "prompt_file=$PROMPT_FILE" >> "$GITHUB_OUTPUT"
488 + echo "preflight_report_json=$PREFLIGHT_JSON" >> "$GITHUB_OUTPUT"
489 + echo "preflight_report_md=$PREFLIGHT_MD" >> "$GITHUB_OUTPUT"
490
491 - name: Install Copilot CLI
492 id: install-copilot
493 run: npm install -g @github/copilot
494
495 - name: Run analysis
459 - if: steps.preflight-cost.outcome == 'success'
496 + if: steps.prompt-preflight.outcome == 'success'
497 id: run-analysis
498 env:
499 COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GH_TOKEN }}
@@ -473,18 +510,7 @@ jobs:
510 ANALYSIS_STARTED=$(date +%s)
511 DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
512 mkdir -p data/metrics "$(dirname "$OUTPUT_FILE")" "$DIAGNOSTICS_DIR"
476 - # Hydrate metrics ledger from publish so track_token_usage.py appends to
477 - # the canonical token-usage.jsonl rather than starting fresh each run.
478 - git fetch origin publish 2>/dev/null && \
479 - git checkout origin/publish -- data/metrics/ 2>/dev/null || true
480 - PROMPT_FILE="data/metrics/analysis-prompt-${WEEK}.md"
481 - rm -f "$PROMPT_FILE"
482 - python3 scripts/analyze_fallback.py \
483 - --raw-json "$WEEK_FILE" \
484 - --output "$OUTPUT_FILE" \
485 - --current-datetime "$CURRENT_DATETIME" \
486 - --press-context "$PRESS_FILE" \
487 - --print-prompt > "$PROMPT_FILE"
513 + PROMPT_FILE="${{ steps.prompt-preflight.outputs.prompt_file }}"
514
515 sanitize_agent_output() {
516 python3 scripts/sanitize_agent_output.py --path "$1"
@@ -700,6 +726,7 @@ jobs:
726 RUN_MODE: ${{ steps.analysis-context.outputs.run_mode }}
727 SOURCE_REFRESH_POLICY: ${{ steps.analysis-context.outputs.source_refresh_policy }}
728 GATE_REPORT: ${{ steps.analysis-context.outputs.analysis_gate_report_file }}
729 + PREFLIGHT_REPORT: ${{ steps.prompt-preflight.outputs.preflight_report_json }}
730 run: |
731 set -euo pipefail
732 git fetch origin publish 2>/dev/null && git checkout origin/publish -- "$PUBLISHED_SUMMARY" 2>/dev/null || true
@@ -722,6 +749,7 @@ jobs:
749 --raw-json "$RAW_JSON_FILE" \
750 --analysis-source "$ANALYSIS_SOURCE" \
751 --analysis-model "$ANALYSIS_MODEL" \
752 + --preflight-report "$PREFLIGHT_REPORT" \
753 --validation-status "$VALIDATION_STATUS" \
754 --run-mode "$RUN_MODE" \
755 --source-refresh-policy "$SOURCE_REFRESH_POLICY" \
@@ -1320,16 +1348,11 @@ jobs:
1348 --allow-tool=grep \
1349 > /dev/null; then
1350 echo "✅ Reskill via Copilot CLI with agent identity"
1323 - # Fallback: GitHub Models API via reskill.py
1324 - elif python3 scripts/reskill.py --current-datetime "$CURRENT_DATETIME" --output "$RESKILL_OUTPUT" --prompt-output "$RESKILL_PROMPT"; then
1325 - RESKILL_SOURCE="github-models"
1326 - RESKILL_MODEL="${GITHUB_MODELS_MODEL}"
1327 - echo "⚠️ Copilot CLI unavailable; used GitHub Models API fallback."
1351 else
1352 RESKILL_SOURCE="none"
1353 RESKILL_MODEL="none"
1354 rm -f "$RESKILL_PROMPT"
1332 - echo "Reskill failed on all paths; writing placeholder trigger log."
1355 + echo "Copilot CLI reskill failed; no GitHub Models/OpenAI reskill fallback is configured. Writing placeholder trigger log."
1356 echo "Reskill triggered at run #$COUNTER ($CURRENT_DATETIME)" >> .squad/reskill/trigger-log.txt
1357 fi
1358
prompts/analyze-weekly.md
+2 -2
@@ -35,8 +35,8 @@ Use this only if it is provided. If it is missing, unavailable, or empty, say so
35
36 The analyze job must resolve both learned-state placeholders before invoking Copilot CLI. Weekly AI analysis is Copilot-only; there is no GitHub Models/OpenAI fallback configured for this repository.
37
38 -1. Read `.squad/identity/wisdom.md` and inject its current contents into `{{WISDOM}}`.
39 -2. Read markdown files under `.squad/skills/` (for example `SKILL.md` files in nested skill folders), concatenate them in a stable sorted order, and inject that bundle into `{{SKILLS}}`.
38 +1. Inject only the analysis/topic-specific wisdom capsule into `{{WISDOM}}` (for this topic, the `.squad/topics/<topic>/wisdom.md` learning state or configured equivalent).
39 +2. Inject only analysis/topic-specific skill markdown into `{{SKILLS}}`, in stable sorted order. Do not include unrelated squad workflow, UI, PR-review, or release-process skills.
40 3. If either source is missing or empty, inject a short explicit note rather than leaving the placeholder unresolved.
41 4. Treat learned context as guidance that sharpens judgment, not as permission to ignore the current week's evidence.
42
scripts/analyze_fallback.py
+426 -33
@@ -2,11 +2,13 @@
2 from __future__ import annotations
3
4 import argparse
5 +import hashlib
6 import json
7 import os
8 import random
9 import sys
10 import time
11 +from dataclasses import asdict, dataclass
12 from pathlib import Path
13 from typing import Any
14 from urllib import error, request
@@ -26,10 +28,45 @@ DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
28 DEFAULT_MODELS_MODEL = "openai/gpt-4o"
29 DEFAULT_MODELS_TIMEOUT = 30
30 NO_AI_DIAGNOSTIC_QUALITY_SCORE = 40
31 +DEFAULT_PROMPT_TOKEN_BUDGET = 90_000
32 +COMPACTED_NEW_REPOS_LIMIT = 25
33 +COMPACTED_TRENDING_REPOS_LIMIT = 25
34 +COMPACTED_PREVIOUS_SUMMARY_CHARS = 8_000
35 +COMPACTED_WISDOM_CHARS = 8_000
36 +COMPACTED_SKILLS_CHARS = 10_000
37 +COMPACTED_PRESS_CONTEXT_CHARS = 14_000
38 +
39 +
40 +@dataclass
41 +class PromptComponent:
42 + name: str
43 + path: str | None
44 + included: bool
45 + inclusion_reason: str
46 + compaction_decision: str
47 + bytes: int
48 + token_estimate: int
49 + checksum_sha256: str
50 +
51 +
52 +@dataclass
53 +class PromptPreflight:
54 + prompt_token_budget: int
55 + prompt_tokens: int
56 + prompt_bytes: int
57 + prompt_checksum_sha256: str
58 + prompt_within_budget: bool
59 + degraded: bool
60 + publish_eligible: bool
61 + promotion_policy: str
62 + degradation_reason: str | None
63 + fallback_policy: str
64 + components: list[PromptComponent]
65 + deterministic_slices: list[str]
66
67
68 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
32 - parser = argparse.ArgumentParser(description="Fallback weekly analysis via GitHub Models API.")
69 + parser = argparse.ArgumentParser(description="Render/preflight weekly analysis prompts or generate diagnostic no-AI output.")
70 parser.add_argument("--raw-json", required=True, type=Path, help="Path to the weekly raw JSON payload.")
71 parser.add_argument("--output", required=True, type=Path, help="Path to write the analyzed markdown output.")
72 parser.add_argument("--current-datetime", required=True, help="ISO-8601 timestamp for the analysis run.")
@@ -73,6 +110,22 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
110 action="store_true",
111 help="Generate a data-only summary without calling any AI API.",
112 )
113 + parser.add_argument(
114 + "--prompt-token-budget",
115 + type=int,
116 + default=DEFAULT_PROMPT_TOKEN_BUDGET,
117 + help=f"Maximum rendered prompt tokens before model invocation (default: {DEFAULT_PROMPT_TOKEN_BUDGET}).",
118 + )
119 + parser.add_argument(
120 + "--preflight-report-json",
121 + type=Path,
122 + help="Write deterministic rendered-prompt preflight details as JSON.",
123 + )
124 + parser.add_argument(
125 + "--preflight-report-md",
126 + type=Path,
127 + help="Write deterministic rendered-prompt preflight details as Markdown.",
128 + )
129 return parser.parse_args(argv)
130
131
@@ -80,6 +133,90 @@ def load_json(path: Path) -> dict[str, Any]:
133 return json.loads(path.read_text(encoding="utf-8"))
134
135
136 +def estimate_tokens(text: str) -> int:
137 + """Deterministic local estimate used for preflight bounds."""
138 + return (len(text.encode("utf-8")) + 3) // 4
139 +
140 +
141 +def checksum_text(text: str) -> str:
142 + return hashlib.sha256(text.encode("utf-8")).hexdigest()
143 +
144 +
145 +def _component(
146 + *,
147 + name: str,
148 + content: str,
149 + path: Path | None,
150 + included: bool,
151 + inclusion_reason: str,
152 + compaction_decision: str,
153 +) -> PromptComponent:
154 + return PromptComponent(
155 + name=name,
156 + path=path.as_posix() if path else None,
157 + included=included,
158 + inclusion_reason=inclusion_reason,
159 + compaction_decision=compaction_decision,
160 + bytes=len(content.encode("utf-8")),
161 + token_estimate=estimate_tokens(content),
162 + checksum_sha256=checksum_text(content),
163 + )
164 +
165 +
166 +def truncate_with_notice(content: str, limit: int, label: str) -> tuple[str, str]:
167 + if len(content) <= limit:
168 + return content, "included"
169 + omitted = len(content) - limit
170 + return (
171 + content[:limit].rstrip()
172 + + f"\n\n[Preflight compaction: truncated {label}; omitted {omitted} characters to stay within prompt budget.]",
173 + "compacted",
174 + )
175 +
176 +
177 +def _load_yaml(path: Path) -> dict[str, Any]:
178 + try:
179 + import yaml # type: ignore[import-untyped]
180 + except ImportError:
181 + return {}
182 + if not path.exists():
183 + return {}
184 + with open(path, encoding="utf-8") as f:
185 + payload = yaml.safe_load(f) or {}
186 + return payload if isinstance(payload, dict) else {}
187 +
188 +
189 +def _resolve_existing_path(configured: str | None, fallback: Path) -> Path:
190 + candidates: list[Path] = []
191 + if configured:
192 + configured_path = Path(configured)
193 + candidates.append(configured_path if configured_path.is_absolute() else ROOT / configured_path)
194 + if not configured_path.is_absolute():
195 + candidates.append(ROOT / ".squad" / configured_path)
196 + candidates.append(fallback)
197 + for candidate in candidates:
198 + if candidate.exists():
199 + return candidate
200 + return candidates[0] if candidates else fallback
201 +
202 +
203 +def resolve_analysis_context_paths() -> tuple[Path, Path]:
204 + """Resolve analysis-specific learned context, avoiding unrelated squad workflow context."""
205 + config = _load_yaml(ROOT / "squadscope.topic.yml")
206 + topic = config.get("topic") if isinstance(config.get("topic"), dict) else {}
207 + learning = config.get("learning") if isinstance(config.get("learning"), dict) else {}
208 + topic_id = str(topic.get("id") or "general")
209 + wisdom_path = _resolve_existing_path(
210 + learning.get("wisdom_file"),
211 + ROOT / ".squad" / "topics" / topic_id / "wisdom.md",
212 + )
213 + skills_path = _resolve_existing_path(
214 + learning.get("skills_dir"),
215 + ROOT / ".squad" / "topics" / topic_id / "skills",
216 + )
217 + return wisdom_path, skills_path
218 +
219 +
220 def find_previous_summary(current_week: str, analyzed_dir: Path) -> Path | None:
221 if not analyzed_dir.exists():
222 return None
@@ -121,7 +258,43 @@ def render_skills(skills_dir: Path) -> str:
258 return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
259
260
124 -def render_prompt(
261 +def _sort_repos_for_compaction(repos: list[dict[str, Any]], score_key: str) -> list[dict[str, Any]]:
262 + return sorted(
263 + repos,
264 + key=lambda repo: (
265 + int(repo.get(score_key) or 0),
266 + int(repo.get("stars") or 0),
267 + str(repo.get("full_name") or ""),
268 + ),
269 + reverse=True,
270 + )
271 +
272 +
273 +def compact_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]:
274 + compacted = dict(payload)
275 + decisions = {"new_repos": "included", "trending_repos": "included"}
276 + new_repos = payload.get("new_repos")
277 + if isinstance(new_repos, list) and len(new_repos) > COMPACTED_NEW_REPOS_LIMIT:
278 + compacted["new_repos"] = _sort_repos_for_compaction(new_repos, "stars")[:COMPACTED_NEW_REPOS_LIMIT]
279 + decisions["new_repos"] = f"compacted to top {COMPACTED_NEW_REPOS_LIMIT} repos by stars"
280 + trending_repos = payload.get("trending_repos")
281 + if isinstance(trending_repos, list) and len(trending_repos) > COMPACTED_TRENDING_REPOS_LIMIT:
282 + compacted["trending_repos"] = _sort_repos_for_compaction(trending_repos, "stars_gained")[
283 + :COMPACTED_TRENDING_REPOS_LIMIT
284 + ]
285 + decisions["trending_repos"] = f"compacted to top {COMPACTED_TRENDING_REPOS_LIMIT} repos by stars_gained/stars"
286 + if decisions["new_repos"] != "included" or decisions["trending_repos"] != "included":
287 + compacted["_preflight_compaction"] = {
288 + "reason": "Rendered prompt exceeded explicit token budget before model invocation.",
289 + "new_repos_original_count": len(new_repos) if isinstance(new_repos, list) else 0,
290 + "trending_repos_original_count": len(trending_repos) if isinstance(trending_repos, list) else 0,
291 + "new_repos_decision": decisions["new_repos"],
292 + "trending_repos_decision": decisions["trending_repos"],
293 + }
294 + return compacted, decisions
295 +
296 +
297 +def _build_prompt(
298 *,
299 prompt_template_path: Path,
300 raw_json_path: Path,
@@ -131,44 +304,244 @@ def render_prompt(
304 wisdom_file: Path = DEFAULT_WISDOM_FILE,
305 skills_dir: Path = DEFAULT_SKILLS_DIR,
306 press_context_path: Path | None = None,
134 -) -> str:
307 + prompt_token_budget: int = DEFAULT_PROMPT_TOKEN_BUDGET,
308 + allow_compaction: bool = True,
309 +) -> tuple[str, PromptPreflight]:
310 payload = load_json(raw_json_path)
311 sanitized_payload = sanitize_repo_payload(payload)
312 current_week = sanitized_payload["week"]
313 previous_summary_path = find_previous_summary(current_week, analyzed_dir)
314 previous_summary_content = previous_summary_path.read_text(encoding="utf-8") if previous_summary_path else ""
140 - raw_json_content = json.dumps(sanitized_payload, indent=2, ensure_ascii=False)
315 + wisdom_content = render_wisdom(wisdom_file)
316 + skills_content = render_skills(skills_dir)
317 + press_content = (
318 + press_context_path.read_text(encoding="utf-8").strip()
319 + if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
320 + else ""
321 + )
322 + payload_for_prompt = sanitized_payload
323 + raw_decisions = {"new_repos": "included", "trending_repos": "included"}
324 + previous_decision = "included" if previous_summary_path else "not included: no previous summary"
325 + wisdom_decision = "included" if wisdom_file.exists() else "not included: no analysis-specific wisdom file"
326 + skills_decision = "included" if skills_dir.exists() and iter_skill_files(skills_dir) else "not included: no analysis-specific skills"
327 + press_decision = "included" if press_content else "not included: no press context"
328 + degraded = False
329 +
330 + def assemble() -> str:
331 + raw_json_content = json.dumps(payload_for_prompt, indent=2, ensure_ascii=False)
332 + current_year, _, week_number = current_week.partition("-W")
333 + generic_title_example = (
334 + f"Week {int(week_number)}, {current_year} Analysis" if week_number.isdigit() else "Week NN, YYYY Analysis"
335 + )
336 + prompt = prompt_template_path.read_text(encoding="utf-8")
337 + replacements = {
338 + "{{CURRENT_DATETIME}}": current_datetime,
339 + "{{CURRENT_WEEK}}": current_week,
340 + "{{CURRENT_YEAR}}": current_year,
341 + "{{TITLE_TEMPLATE_HINT}}": (
342 + f"Specific editorial headline about {current_week}'s dominant themes "
343 + f"(not \"{generic_title_example}\")"
344 + ),
345 + "{{RAW_JSON_PATH}}": str(raw_json_path),
346 + "{{OUTPUT_PATH}}": str(output_path),
347 + "{{PREVIOUS_SUMMARY_PATH_OR_NONE}}": str(previous_summary_path) if previous_summary_path else "None",
348 + "{{RAW_JSON_CONTENT}}": raw_json_content,
349 + "{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}": previous_summary_content.strip(),
350 + "{{WISDOM}}": wisdom_content,
351 + "{{SKILLS}}": skills_content,
352 + }
353 + for needle, value in replacements.items():
354 + prompt = prompt.replace(needle, value)
355 + if press_content:
356 + prompt += f"\n\n---\n## Press Context\n\n{press_content}\n"
357 + return prompt
358 +
359 + prompt = assemble()
360 + if allow_compaction and estimate_tokens(prompt) > prompt_token_budget:
361 + degraded = True
362 + payload_for_prompt, raw_decisions = compact_payload(sanitized_payload)
363 + previous_summary_content, previous_decision = truncate_with_notice(
364 + previous_summary_content, COMPACTED_PREVIOUS_SUMMARY_CHARS, "prior continuity"
365 + )
366 + wisdom_content, wisdom_decision = truncate_with_notice(wisdom_content, COMPACTED_WISDOM_CHARS, "analysis wisdom")
367 + skills_content, skills_decision = truncate_with_notice(skills_content, COMPACTED_SKILLS_CHARS, "analysis skills")
368 + press_content, press_decision = truncate_with_notice(
369 + press_content, COMPACTED_PRESS_CONTEXT_CHARS, "press correlations"
370 + )
371 + prompt = assemble()
372 +
373 + raw_json_content = json.dumps(payload_for_prompt, indent=2, ensure_ascii=False)
374 current_year, _, week_number = current_week.partition("-W")
142 - generic_title_example = f"Week {int(week_number)}, {current_year} Analysis" if week_number.isdigit() else "Week NN, YYYY Analysis"
143 -
144 - prompt = prompt_template_path.read_text(encoding="utf-8")
145 - replacements = {
146 - "{{CURRENT_DATETIME}}": current_datetime,
147 - "{{CURRENT_WEEK}}": current_week,
148 - "{{CURRENT_YEAR}}": current_year,
149 - "{{TITLE_TEMPLATE_HINT}}": (
150 - f"Specific editorial headline about {current_week}'s dominant themes "
151 - f"(not \"{generic_title_example}\")"
375 + components = [
376 + _component(
377 + name="prompt_template",
378 + content=prompt_template_path.read_text(encoding="utf-8"),
379 + path=prompt_template_path,
380 + included=True,
381 + inclusion_reason="Base weekly analysis instructions.",
382 + compaction_decision="included",
383 ),
153 - "{{RAW_JSON_PATH}}": str(raw_json_path),
154 - "{{OUTPUT_PATH}}": str(output_path),
155 - "{{PREVIOUS_SUMMARY_PATH_OR_NONE}}": str(previous_summary_path) if previous_summary_path else "None",
156 - "{{RAW_JSON_CONTENT}}": raw_json_content,
157 - "{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}": previous_summary_content.strip(),
158 - "{{WISDOM}}": render_wisdom(wisdom_file),
159 - "{{SKILLS}}": render_skills(skills_dir),
160 - }
161 - for needle, value in replacements.items():
162 - prompt = prompt.replace(needle, value)
384 + _component(
385 + name="new_repos",
386 + content=json.dumps(payload_for_prompt.get("new_repos", []), indent=2, ensure_ascii=False),
387 + path=raw_json_path,
388 + included=True,
389 + inclusion_reason="Deterministic mapper slice: newly discovered repositories.",
390 + compaction_decision=raw_decisions["new_repos"],
391 + ),
392 + _component(
393 + name="trending_repos",
394 + content=json.dumps(payload_for_prompt.get("trending_repos", []), indent=2, ensure_ascii=False),
395 + path=raw_json_path,
396 + included=True,
397 + inclusion_reason="Deterministic mapper slice: continuing/trending repositories.",
398 + compaction_decision=raw_decisions["trending_repos"],
399 + ),
400 + _component(
401 + name="raw_metadata",
402 + content=raw_json_content,
403 + path=raw_json_path,
404 + included=True,
405 + inclusion_reason=f"Sanitized current weekly payload for {current_year}-W{week_number}.",
406 + compaction_decision="included" if not degraded else "included with compacted repo slices",
407 + ),
408 + _component(
409 + name="prior_continuity",
410 + content=previous_summary_content,
411 + path=previous_summary_path,
412 + included=bool(previous_summary_path),
413 + inclusion_reason="Deterministic mapper slice: prior weekly continuity.",
414 + compaction_decision=previous_decision,
415 + ),
416 + _component(
417 + name="analysis_wisdom",
418 + content=wisdom_content,
419 + path=wisdom_file,
420 + included=wisdom_file.exists(),
421 + inclusion_reason="Analysis-specific wisdom capsule from topic learning state.",
422 + compaction_decision=wisdom_decision,
423 + ),
424 + _component(
425 + name="analysis_skills",
426 + content=skills_content,
427 + path=skills_dir,
428 + included=skills_dir.exists() and bool(iter_skill_files(skills_dir)),
429 + inclusion_reason="Analysis-specific learned skill capsule from topic learning state.",
430 + compaction_decision=skills_decision,
431 + ),
432 + _component(
433 + name="press_correlations",
434 + content=press_content,
435 + path=press_context_path,
436 + included=bool(press_content),
437 + inclusion_reason="Deterministic mapper slice: press/developer correlation context.",
438 + compaction_decision=press_decision,
439 + ),
440 + _component(
441 + name="rendered_prompt",
442 + content=prompt,
443 + path=None,
444 + included=True,
445 + inclusion_reason="Exact prompt that will be passed to Copilot CLI.",
446 + compaction_decision="included" if not degraded else "included after deterministic compaction",
447 + ),
448 + ]
449 + prompt_tokens = estimate_tokens(prompt)
450 + prompt_within_budget = prompt_tokens <= prompt_token_budget
451 + degradation_reason = (
452 + "Prompt was deterministically compacted to fit the configured token budget." if degraded else None
453 + )
454 + preflight = PromptPreflight(
455 + prompt_token_budget=prompt_token_budget,
456 + prompt_tokens=prompt_tokens,
457 + prompt_bytes=len(prompt.encode("utf-8")),
458 + prompt_checksum_sha256=checksum_text(prompt),
459 + prompt_within_budget=prompt_within_budget,
460 + degraded=degraded,
461 + publish_eligible=prompt_within_budget and not degraded,
462 + promotion_policy=(
463 + "normal-promotion"
464 + if not degraded
465 + else "staged/candidate-only by default; degraded compacted output requires an explicit future promotion policy."
466 + ),
467 + degradation_reason=degradation_reason,
468 + fallback_policy=(
469 + "copilot-only; no GitHub Models/OpenAI fallback. no-ai is diagnostic/staged-only and publish-ineligible. "
470 + "degraded/compacted prompts are staged/candidate-only by default."
471 + ),
472 + components=components,
473 + deterministic_slices=["new_repos", "trending_repos", "press_correlations", "prior_continuity"],
474 + )
475 + return prompt, preflight
476
164 - # Append press context if available
165 - if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0:
166 - press_content = press_context_path.read_text(encoding="utf-8").strip()
167 - prompt += f"\n\n---\n## Press Context\n\n{press_content}\n"
477
478 +def render_prompt(
479 + *,
480 + prompt_template_path: Path,
481 + raw_json_path: Path,
482 + output_path: Path,
483 + current_datetime: str,
484 + analyzed_dir: Path,
485 + wisdom_file: Path = DEFAULT_WISDOM_FILE,
486 + skills_dir: Path = DEFAULT_SKILLS_DIR,
487 + press_context_path: Path | None = None,
488 +) -> str:
489 + prompt, _ = _build_prompt(
490 + prompt_template_path=prompt_template_path,
491 + raw_json_path=raw_json_path,
492 + output_path=output_path,
493 + current_datetime=current_datetime,
494 + analyzed_dir=analyzed_dir,
495 + wisdom_file=wisdom_file,
496 + skills_dir=skills_dir,
497 + press_context_path=press_context_path,
498 + allow_compaction=False,
499 + )
500 return prompt
501
502
503 +def write_preflight_reports(preflight: PromptPreflight, json_path: Path | None, md_path: Path | None) -> None:
504 + if json_path:
505 + json_path.parent.mkdir(parents=True, exist_ok=True)
506 + json_path.write_text(json.dumps(asdict(preflight), indent=2, sort_keys=True) + "\n", encoding="utf-8")
507 + if md_path:
508 + md_path.parent.mkdir(parents=True, exist_ok=True)
509 + rows = [
510 + "# Analysis Prompt Preflight",
511 + "",
512 + f"- Prompt budget: `{preflight.prompt_token_budget}` tokens",
513 + f"- Rendered prompt: `{preflight.prompt_tokens}` tokens / `{preflight.prompt_bytes}` bytes",
514 + f"- Prompt checksum: `{preflight.prompt_checksum_sha256}`",
515 + f"- Degraded/compacted: `{str(preflight.degraded).lower()}`",
516 + f"- Degradation reason: {preflight.degradation_reason or 'none'}",
517 + f"- Publish eligible: `{str(preflight.publish_eligible).lower()}`",
518 + f"- Promotion policy: {preflight.promotion_policy}",
519 + f"- Fallback policy: {preflight.fallback_policy}",
520 + f"- Deterministic slices: {', '.join(preflight.deterministic_slices)}",
521 + "",
522 + "| Component | Included | Bytes | Tokens | Checksum | Path | Inclusion reason | Compaction decision |",
523 + "| --- | --- | ---: | ---: | --- | --- | --- | --- |",
524 + ]
525 + for component in preflight.components:
526 + rows.append(
527 + "| "
528 + + " | ".join(
529 + [
530 + component.name,
531 + str(component.included).lower(),
532 + str(component.bytes),
533 + str(component.token_estimate),
534 + component.checksum_sha256,
535 + component.path or "",
536 + component.inclusion_reason.replace("|", "\\|"),
537 + component.compaction_decision.replace("|", "\\|"),
538 + ]
539 + )
540 + + " |"
541 + )
542 + md_path.write_text("\n".join(rows) + "\n", encoding="utf-8")
543 +
544 +
545 def extract_markdown(response_payload: dict[str, Any]) -> str:
546 choices = response_payload.get("choices") or []
547 if not choices:
@@ -514,25 +887,45 @@ Week {week_num} of {year_str} captured {repos_featured} repositories with {total
887
888 def main(argv: list[str] | None = None) -> int:
889 args = parse_args(argv)
517 - prompt = render_prompt(
890 + wisdom_file = args.wisdom_file
891 + skills_dir = args.skills_dir
892 + if wisdom_file == DEFAULT_WISDOM_FILE and skills_dir == DEFAULT_SKILLS_DIR:
893 + wisdom_file, skills_dir = resolve_analysis_context_paths()
894 +
895 + prompt, preflight = _build_prompt(
896 prompt_template_path=args.prompt_template,
897 raw_json_path=args.raw_json,
898 output_path=args.output,
899 current_datetime=args.current_datetime,
900 analyzed_dir=args.analyzed_dir,
523 - wisdom_file=args.wisdom_file,
524 - skills_dir=args.skills_dir,
901 + wisdom_file=wisdom_file,
902 + skills_dir=skills_dir,
903 press_context_path=args.press_context,
904 + prompt_token_budget=args.prompt_token_budget,
905 + allow_compaction=True,
906 )
907 + write_preflight_reports(preflight, args.preflight_report_json, args.preflight_report_md)
908 +
909 + if not preflight.prompt_within_budget:
910 + print(
911 + "::error::Rendered analysis prompt exceeds explicit budget after deterministic compaction: "
912 + f"{preflight.prompt_tokens}/{preflight.prompt_token_budget} tokens.",
913 + file=sys.stderr,
914 + )
915 + return 1
916
917 if args.print_prompt:
529 - print(prompt)
918 + sys.stdout.write(prompt)
919 return 0
920
921 if args.no_ai:
922 markdown = generate_no_ai_summary(args.raw_json, args.current_datetime, args.press_context)
923 else:
535 - markdown = call_github_models(prompt)
924 + print(
925 + "::error::GitHub Models/OpenAI analysis fallback is disabled; use Copilot CLI or --no-ai for staged diagnostics.",
926 + file=sys.stderr,
927 + )
928 + return 1
929
930 args.output.parent.mkdir(parents=True, exist_ok=True)
931 args.output.write_text(markdown, encoding="utf-8")
scripts/copilot_failure.py
+10 -2
@@ -62,6 +62,14 @@ TRANSIENT_PATTERNS = (
62
63 def classify_log(log_text: str, exit_code: int | None = None) -> CopilotFailure:
64 normalized = log_text.lower()
65 + if "copilot is not available" in normalized or "command not found" in normalized:
66 + return CopilotFailure(
67 + "copilot_inaccessible",
68 + retryable=False,
69 + actionable=True,
70 + diagnostic="Copilot CLI is unavailable in the runner; install Copilot CLI or verify runner access.",
71 + exit_code=exit_code,
72 + )
73 if any(pattern in normalized for pattern in TOKEN_PATTERNS):
74 return CopilotFailure(
75 "copilot_token_failure",
@@ -96,10 +104,10 @@ def classify_log(log_text: str, exit_code: int | None = None) -> CopilotFailure:
104 )
105 if any(pattern in normalized for pattern in INACCESSIBLE_PATTERNS):
106 return CopilotFailure(
99 - "copilot_inaccessible",
107 + "copilot_token_failure",
108 retryable=False,
109 actionable=True,
102 - diagnostic="Copilot is inaccessible for this workflow; verify Copilot availability and permissions.",
110 + diagnostic="Copilot authentication/access failure; renew COPILOT_GH_TOKEN or verify Copilot permissions.",
111 exit_code=exit_code,
112 )
113 return CopilotFailure(
scripts/publish_manifest.py
+44 -1
@@ -11,7 +11,7 @@ from typing import Any
11
12
13 SCHEMA_VERSION = "publish_eligibility_v1"
14 -AI_SOURCES = {"copilot-cli", "github-models"}
14 +AI_SOURCES = {"copilot-cli"}
15 RUN_MODES = {"normal", "dry-run", "restore", "force-replace", "candidate-only"}
16 SOURCE_REFRESH_POLICIES = {"reuse-same-day", "refresh-missing-stale", "force-refresh"}
17 ALLOWED_PROMOTION_MANIFEST_ROOTS = {("data", "staging"), ("data", "candidates")}
@@ -42,6 +42,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
42 create.add_argument("--raw-json", required=True, type=Path)
43 create.add_argument("--analysis-source", required=True)
44 create.add_argument("--analysis-model", default="copilot-default")
45 + create.add_argument(
46 + "--preflight-report",
47 + type=Path,
48 + help="Analysis preflight report JSON used to decide whether Copilot output is normally promotable.",
49 + )
50 create.add_argument("--validation-status", choices=["passed", "failed"], required=True)
51 create.add_argument("--run-mode", choices=sorted(RUN_MODES), default="normal")
52 create.add_argument("--source-refresh-policy", choices=sorted(SOURCE_REFRESH_POLICIES), default="reuse-same-day")
@@ -111,6 +116,24 @@ def load_json(path: Path) -> dict[str, Any] | None:
116 return payload if isinstance(payload, dict) else None
117
118
119 +def load_preflight(path: Path | None, *, required: bool = False) -> tuple[dict[str, Any] | None, list[str]]:
120 + if path is None:
121 + if required:
122 + return None, ["preflight report is required for Copilot CLI promotion"]
123 + return None, []
124 + payload = load_json(path)
125 + if payload is None:
126 + return None, [f"preflight report missing or malformed: {path}"]
127 + reasons: list[str] = []
128 + if payload.get("degraded") is True:
129 + reasons.append(
130 + "preflight degraded/compacted; candidate is staged-only unless an explicit promotion policy allows it"
131 + )
132 + if payload.get("publish_eligible") is not True:
133 + reasons.append("preflight report marks candidate as publish-ineligible")
134 + return payload, reasons
135 +
136 +
137 def manifest_lives_under_allowed_promotion_root(manifest_path: Path, root: Path | None = None) -> bool:
138 workspace = (root or Path.cwd()).resolve()
139 resolved_manifest = manifest_path if manifest_path.is_absolute() else workspace / manifest_path
@@ -463,6 +486,7 @@ def create_manifest(args: argparse.Namespace) -> int:
486 analysis_source = args.analysis_source.strip()
487 ai_status = "ai" if analysis_source in AI_SOURCES else "no-ai" if analysis_source == NO_AI_SOURCE else "unknown"
488 model_status = publishable_model_status(args.analysis_model)
489 + preflight, preflight_reasons = load_preflight(args.preflight_report, required=ai_status == "ai")
490 gate_report = load_gate_report(args.gate_report)
491 candidate_metadata = markdown_metadata(args.summary)
492 published_status = published_summary_status(args.published_summary, args.week)
@@ -524,6 +548,7 @@ def create_manifest(args: argparse.Namespace) -> int:
548 reasons.append("analysis validation did not pass")
549 if ai_status not in {"ai", "no-ai"}:
550 reasons.append(f"analysis source is not AI-publishable: {analysis_source or 'unknown'}")
551 + reasons.extend(preflight_reasons)
552 if not mode_allows_promotion:
553 reasons.append(f"run mode {args.run_mode} is non-publishing")
554 if ai_status == "ai" and model_status != "available":
@@ -540,6 +565,7 @@ def create_manifest(args: argparse.Namespace) -> int:
565 and gates_passed
566 and not artifact_reasons
567 and not comparison_reasons
568 + and not preflight_reasons
569 and mode_allows_promotion
570 and not reasons
571 and (
@@ -577,12 +603,23 @@ def create_manifest(args: argparse.Namespace) -> int:
603 "model": args.analysis_model,
604 "model_status": model_status,
605 "provider": analysis_source,
606 + "preflight": {
607 + "path": args.preflight_report.as_posix() if args.preflight_report else None,
608 + "degraded": preflight.get("degraded") if preflight else None,
609 + "publish_eligible": preflight.get("publish_eligible") if preflight else None,
610 + "prompt_tokens": preflight.get("prompt_tokens") if preflight else None,
611 + "prompt_token_budget": preflight.get("prompt_token_budget") if preflight else None,
612 + "prompt_checksum_sha256": preflight.get("prompt_checksum_sha256") if preflight else None,
613 + "promotion_policy": preflight.get("promotion_policy") if preflight else None,
614 + "degradation_reason": preflight.get("degradation_reason") if preflight else None,
615 + },
616 "provenance": {
617 "run_id": args.run_id,
618 "current_datetime": args.current_datetime,
619 "authorship": "ai-authored" if ai_status == "ai" else "no-ai-fallback" if ai_status == "no-ai" else "unknown",
620 "provider": analysis_source,
621 "model": args.analysis_model,
622 + "degraded": preflight.get("degraded") if preflight else None,
623 "fallback_reason": args.fallback_reason.strip() or None,
624 "attempted_ai_paths": attempted_ai_paths,
625 },
@@ -674,6 +711,12 @@ def assert_eligible(args: argparse.Namespace) -> int:
711 ai_status = analysis.get("ai_status")
712 if ai_status == "ai" and analysis.get("model_status") != "available":
713 raise SystemExit("Manifest lacks an available AI model.")
714 + if ai_status == "ai":
715 + preflight = analysis.get("preflight")
716 + if not isinstance(preflight, dict) or preflight.get("publish_eligible") is not True:
717 + raise SystemExit("Manifest lacks a publish-eligible Copilot preflight report.")
718 + if preflight.get("degraded") is True:
719 + raise SystemExit("Manifest preflight is degraded/compacted and staged-only by default.")
720 validation = payload.get("validation")
721 gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
722 if not isinstance(gate_report, dict) or gate_report.get("present") is not True or gate_report.get("passed") is not True:
tests/test_analyze_fallback.py
+127 -11
@@ -180,6 +180,125 @@ class AnalyzeFallbackTests(unittest.TestCase):
180 self.assertNotIn("{{WISDOM}}", prompt)
181 self.assertNotIn("{{SKILLS}}", prompt)
182
183 + def test_main_writes_prompt_preflight_report_for_exact_rendered_prompt(self) -> None:
184 + tests_root = Path(__file__).resolve().parent
185 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
186 + base = Path(tmpdir)
187 + raw_path = base / "data" / "raw" / "2026-W21.json"
188 + prompt_template = base / "prompt.md"
189 + output_path = base / "data" / "analyzed" / "2026-W21-summary.md"
190 + report_path = base / "diagnostics" / "preflight.json"
191 + raw_path.parent.mkdir(parents=True)
192 + output_path.parent.mkdir(parents=True)
193 + raw_path.write_text(
194 + json.dumps(
195 + {
196 + "week": "2026-W21",
197 + "new_repos": [{"full_name": "owner/new", "stars": 10}],
198 + "trending_repos": [{"full_name": "owner/trend", "stars": 20, "stars_gained": 5}],
199 + }
200 + ),
201 + encoding="utf-8",
202 + )
203 + prompt_template.write_text("{{RAW_JSON_CONTENT}}\n{{WISDOM}}\n{{SKILLS}}", encoding="utf-8")
204 +
205 + with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
206 + exit_code = analyze_fallback.main(
207 + [
208 + "--raw-json",
209 + str(raw_path),
210 + "--output",
211 + str(output_path),
212 + "--current-datetime",
213 + "2026-05-18T13:05:53.678+02:00",
214 + "--prompt-template",
215 + str(prompt_template),
216 + "--analyzed-dir",
217 + str(output_path.parent),
218 + "--wisdom-file",
219 + str(base / "missing-wisdom.md"),
220 + "--skills-dir",
221 + str(base / "missing-skills"),
222 + "--preflight-report-json",
223 + str(report_path),
224 + "--print-prompt",
225 + ]
226 + )
227 +
228 + rendered = stdout.getvalue()
229 + report = json.loads(report_path.read_text(encoding="utf-8"))
230 + self.assertEqual(exit_code, 0)
231 + self.assertEqual(report["prompt_checksum_sha256"], analyze_fallback.checksum_text(rendered))
232 + self.assertEqual(report["deterministic_slices"], ["new_repos", "trending_repos", "press_correlations", "prior_continuity"])
233 + self.assertFalse(report["degraded"])
234 + self.assertTrue(report["publish_eligible"])
235 + self.assertEqual(report["promotion_policy"], "normal-promotion")
236 + self.assertIn("no-ai is diagnostic/staged-only", report["fallback_policy"])
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 +
241 + def test_preflight_compacts_before_prompt_exceeds_budget(self) -> None:
242 + tests_root = Path(__file__).resolve().parent
243 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
244 + base = Path(tmpdir)
245 + raw_path = base / "data" / "raw" / "2026-W21.json"
246 + prompt_template = base / "prompt.md"
247 + output_path = base / "data" / "analyzed" / "2026-W21-summary.md"
248 + report_path = base / "diagnostics" / "preflight.json"
249 + report_md_path = base / "diagnostics" / "preflight.md"
250 + raw_path.parent.mkdir(parents=True)
251 + output_path.parent.mkdir(parents=True)
252 + raw_path.write_text(
253 + json.dumps(
254 + {
255 + "week": "2026-W21",
256 + "new_repos": [{"full_name": f"owner/new-{i}", "stars": i} for i in range(60)],
257 + "trending_repos": [
258 + {"full_name": f"owner/trend-{i}", "stars": i, "stars_gained": i} for i in range(60)
259 + ],
260 + }
261 + ),
262 + encoding="utf-8",
263 + )
264 + prompt_template.write_text("{{RAW_JSON_CONTENT}}", encoding="utf-8")
265 +
266 + exit_code = analyze_fallback.main(
267 + [
268 + "--raw-json",
269 + str(raw_path),
270 + "--output",
271 + str(output_path),
272 + "--current-datetime",
273 + "2026-05-18T13:05:53.678+02:00",
274 + "--prompt-template",
275 + str(prompt_template),
276 + "--analyzed-dir",
277 + str(output_path.parent),
278 + "--preflight-report-json",
279 + str(report_path),
280 + "--preflight-report-md",
281 + str(report_md_path),
282 + "--prompt-token-budget",
283 + "2000",
284 + "--print-prompt",
285 + ]
286 + )
287 +
288 + report = json.loads(report_path.read_text(encoding="utf-8"))
289 + self.assertEqual(exit_code, 0)
290 + self.assertTrue(report["degraded"])
291 + self.assertFalse(report["publish_eligible"])
292 + self.assertIn("staged/candidate-only", report["promotion_policy"])
293 + self.assertIn("compacted", report["degradation_reason"])
294 + report_markdown = report_md_path.read_text(encoding="utf-8")
295 + self.assertIn("Degraded/compacted: `true`", report_markdown)
296 + self.assertIn("Publish eligible: `false`", report_markdown)
297 + self.assertIn("staged/candidate-only", report_markdown)
298 + components = {component["name"]: component for component in report["components"]}
299 + self.assertIn("compacted to top", components["new_repos"]["compaction_decision"])
300 + self.assertIn("compacted to top", components["trending_repos"]["compaction_decision"])
301 +
302 def test_extract_markdown_supports_message_parts(self) -> None:
303 payload = {
304 "choices": [
@@ -269,7 +388,7 @@ class AnalyzeFallbackTests(unittest.TestCase):
388 self.assertEqual(result.returncode, 0, result.stderr)
389 self.assertIn('week: "2026-W21"', result.stdout)
390
272 - def test_main_writes_fallback_output(self) -> None:
391 + def test_main_without_no_ai_rejects_github_models_fallback(self) -> None:
392 tests_root = Path(__file__).resolve().parent
393 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
394 base = Path(tmpdir)
@@ -281,13 +400,9 @@ class AnalyzeFallbackTests(unittest.TestCase):
400 raw_path.write_text(json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}), encoding="utf-8")
401 prompt_template.write_text("{{RAW_JSON_CONTENT}}", encoding="utf-8")
402
284 - response = _FakeHTTPResponse(
285 - json.dumps({"choices": [{"message": {"content": "# Summary\n"}}]}).encode("utf-8")
286 - )
287 -
288 - with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False), mock.patch.object(
289 - analyze_fallback.request, "urlopen", return_value=response
290 - ) as urlopen_mock:
403 + with mock.patch.object(analyze_fallback.request, "urlopen") as urlopen_mock, mock.patch(
404 + "sys.stderr", new_callable=io.StringIO
405 + ) as stderr:
406 exit_code = analyze_fallback.main(
407 [
408 "--raw-json",
@@ -303,9 +418,10 @@ class AnalyzeFallbackTests(unittest.TestCase):
418 ]
419 )
420
306 - self.assertEqual(exit_code, 0)
307 - self.assertEqual(output_path.read_text(encoding="utf-8"), "# Summary\n")
308 - self.assertEqual(urlopen_mock.call_args.kwargs["timeout"], analyze_fallback.DEFAULT_MODELS_TIMEOUT)
421 + self.assertEqual(exit_code, 1)
422 + self.assertFalse(output_path.exists())
423 + self.assertIn("GitHub Models/OpenAI analysis fallback is disabled", stderr.getvalue())
424 + urlopen_mock.assert_not_called()
425
426 def test_github_models_403_is_non_retryable_access_failure(self) -> None:
427 forbidden = error.HTTPError(
tests/test_copilot_failure.py
+2 -2
@@ -14,8 +14,8 @@ def test_classifies_copilot_token_failure_as_actionable_non_retryable() -> None:
14 assert report.retryable is False
15 assert report.actionable is True
16 assert "renew COPILOT_GH_TOKEN" in report.diagnostic
17 - assert copilot_failure.classify_log("HTTP 403 from Copilot").failure_class == "copilot_inaccessible"
18 - assert copilot_failure.classify_log("HTTP 401 from Copilot").failure_class == "copilot_inaccessible"
17 + assert copilot_failure.classify_log("HTTP 403 from Copilot").failure_class == "copilot_token_failure"
18 + assert copilot_failure.classify_log("HTTP 401 from Copilot").failure_class == "copilot_token_failure"
19 assert copilot_failure.classify_log("HTTP 403 invalid token").failure_class == "copilot_token_failure"
20
21
tests/test_pipeline.py
+27 -10
@@ -218,7 +218,7 @@ class WorkflowConfigTests(unittest.TestCase):
218
219 reskill_step = next((s for s in reskill["steps"] if s.get("name") == "Run reskill"), None)
220 self.assertIsNotNone(reskill_step)
221 - self.assertEqual(workflow["env"]["GITHUB_MODELS_MODEL"], "${{ vars.GITHUB_MODELS_MODEL || 'openai/gpt-4o' }}")
221 + self.assertNotIn("GITHUB_MODELS_MODEL", workflow["env"])
222 self.assertEqual(reskill_step["env"]["COPILOT_GITHUB_TOKEN"], "${{ secrets.COPILOT_GH_TOKEN }}")
223 reskill_run = reskill_step["run"]
224 self.assertIn("python3 scripts/reskill.py --current-datetime", reskill_run)
@@ -231,6 +231,10 @@ class WorkflowConfigTests(unittest.TestCase):
231 self.assertIn("trigger-log.txt", reskill_run)
232 self.assertIn("git add .squad/", reskill_run)
233 self.assertIn("data/metrics/", reskill_run)
234 + self.assertIn("no GitHub Models/OpenAI reskill fallback", reskill_run)
235 + self.assertNotIn("${GITHUB_MODELS_MODEL}", reskill_run)
236 + self.assertNotIn('RESKILL_SOURCE="github-models"', reskill_run)
237 + self.assertNotIn("used GitHub Models API fallback", reskill_run)
238 # Reskill prompt addresses the team, not an individual agent
239 self.assertIn('"Team, take a nap and reskill"', reskill_run)
240 self.assertNotIn("Farnsworth, read the file", reskill_run)
@@ -238,6 +242,17 @@ class WorkflowConfigTests(unittest.TestCase):
242 self.assertIn('RESKILL_PROMPT=".squad/reskill/current-prompt.md"', reskill_run)
243
244 analyze = workflow["jobs"]["analyze"]
245 + preflight_step = next((s for s in analyze["steps"] if s.get("name") == "Render and preflight analysis prompt"), None)
246 + self.assertIsNotNone(preflight_step)
247 + preflight_run = preflight_step["run"]
248 + self.assertIn("--prompt-token-budget", preflight_run)
249 + self.assertIn("--preflight-report-json", preflight_run)
250 + self.assertIn("--preflight-report-md", preflight_run)
251 + self.assertIn("--print-prompt > \"$PROMPT_FILE\"", preflight_run)
252 + self.assertIn("--context-files \"$PROMPT_FILE\"", preflight_run)
253 + self.assertIn("promotion_policy=", preflight_run)
254 + self.assertIn("staged/candidate-only", preflight_run)
255 +
256 run_analysis_step = next((s for s in analyze["steps"] if s.get("name") == "Run analysis"), None)
257 self.assertIsNotNone(run_analysis_step)
258 run_analysis = run_analysis_step["run"]
@@ -262,6 +277,12 @@ class WorkflowConfigTests(unittest.TestCase):
277 self.assertNotIn('ANALYSIS_SOURCE="github-models"', run_analysis)
278 self.assertNotIn("falling back to GitHub Models API", run_analysis)
279
280 + manifest_step = next((s for s in analyze["steps"] if s.get("name") == "Emit publish eligibility manifest"), None)
281 + self.assertIsNotNone(manifest_step)
282 + manifest_run = manifest_step["run"]
283 + self.assertEqual(manifest_step["env"]["PREFLIGHT_REPORT"], "${{ steps.prompt-preflight.outputs.preflight_report_json }}")
284 + self.assertIn('--preflight-report "$PREFLIGHT_REPORT"', manifest_run)
285 +
286 def test_generate_workflow_runs_rollups_and_commits_all_content(self) -> None:
287 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
288 workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
@@ -557,7 +578,7 @@ class PipelineIntegrationTests(unittest.TestCase):
578 self.assertNotIn("quality_score", rendered)
579 self.assertIn("## This Week's Trends", rendered)
580
560 - def test_analyze_fallback_can_process_raw_data(self) -> None:
581 + def test_analyze_fallback_no_ai_can_process_raw_data(self) -> None:
582 tests_root = Path(__file__).resolve().parent
583 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
584 base = Path(tmpdir)
@@ -567,13 +588,7 @@ class PipelineIntegrationTests(unittest.TestCase):
588 output_path.parent.mkdir(parents=True)
589 raw_path.write_text(json.dumps(make_raw_payload()), encoding="utf-8")
590
570 - response = _FakeHTTPResponse(
571 - json.dumps({"choices": [{"message": {"content": make_analysis_markdown()}}]}).encode("utf-8")
572 - )
573 -
574 - with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False), mock.patch.object(
575 - analyze_fallback.request, "urlopen", return_value=response
576 - ):
591 + with mock.patch.object(analyze_fallback.request, "urlopen") as urlopen_mock:
592 exit_code = analyze_fallback.main(
593 [
594 "--raw-json",
@@ -584,13 +599,15 @@ class PipelineIntegrationTests(unittest.TestCase):
599 FIXED_RUN_DATETIME,
600 "--analyzed-dir",
601 str(output_path.parent),
602 + "--no-ai",
603 ]
604 )
605
606 self.assertEqual(exit_code, 0)
607 written = output_path.read_text(encoding="utf-8")
592 - self.assertIn("Reliable Automation Gains Ground", written)
608 + self.assertIn("Automation, Observability, and This Week's Repo Signals", written)
609 self.assertIn("## Signal & Noise", written)
610 + urlopen_mock.assert_not_called()
611
612 def test_analysis_gate_validates_analysis_output_correctly(self) -> None:
613 tests_root = Path(__file__).resolve().parent
tests/test_promotion_guard.py
+55 -27
@@ -104,45 +104,73 @@ def write_gate_report(root: Path, path: Path, *, passed: bool = True) -> None:
104 )
105
106
107 +def write_preflight(root: Path, path: Path, *, degraded: bool = False, publish_eligible: bool = True) -> None:
108 + write_file(
109 + root,
110 + path.as_posix(),
111 + json.dumps(
112 + {
113 + "prompt_token_budget": 90000,
114 + "prompt_tokens": 1200,
115 + "prompt_bytes": 4800,
116 + "prompt_checksum_sha256": "a" * 64,
117 + "prompt_within_budget": True,
118 + "degraded": degraded,
119 + "publish_eligible": publish_eligible,
120 + "promotion_policy": "normal-promotion" if publish_eligible else "staged/candidate-only by default",
121 + "degradation_reason": "Prompt was deterministically compacted." if degraded else None,
122 + "fallback_policy": "copilot-only",
123 + "components": [],
124 + "deterministic_slices": [],
125 + }
126 + )
127 + + "\n",
128 + )
129 +
130 +
131 def create_publish_manifest(root: Path, name: str, *, source: str = "copilot-cli", model: str = "copilot-default", gate_passed: bool = True) -> Path:
132 candidate_dir = Path("data/candidates") / WEEK / name
133 summary_path = candidate_dir / f"{WEEK}-summary.md"
134 manifest_path = candidate_dir / "publish-manifest.json"
135 gate_report = candidate_dir / "analysis-gate-report.json"
136 + preflight_report = candidate_dir / "diagnostics" / "analysis-preflight.json"
137 write_file(root, summary_path.as_posix(), VALID_REPLACEMENT_SUMMARY)
138 write_publish_raw(root)
139 write_gate_report(root, gate_report, passed=gate_passed)
140 + if source == "copilot-cli":
141 + write_preflight(root, preflight_report)
142
143 previous_cwd = Path.cwd()
144 try:
145 os.chdir(root)
119 - publish_manifest.main(
120 - [
121 - "create",
122 - "--week",
123 - WEEK,
124 - "--run-id",
125 - name,
126 - "--current-datetime",
127 - RUN_STARTED_AT,
128 - "--summary",
129 - summary_path.as_posix(),
130 - "--published-summary",
131 - f"data/analyzed/{WEEK}-summary.md",
132 - "--raw-json",
133 - f"data/raw/{WEEK}.json",
134 - "--analysis-source",
135 - source,
136 - "--analysis-model",
137 - model,
138 - "--validation-status",
139 - "passed" if gate_passed else "failed",
140 - "--gate-report",
141 - gate_report.as_posix(),
142 - "--output",
143 - manifest_path.as_posix(),
144 - ]
145 - )
146 + args = [
147 + "create",
148 + "--week",
149 + WEEK,
150 + "--run-id",
151 + name,
152 + "--current-datetime",
153 + RUN_STARTED_AT,
154 + "--summary",
155 + summary_path.as_posix(),
156 + "--published-summary",
157 + f"data/analyzed/{WEEK}-summary.md",
158 + "--raw-json",
159 + f"data/raw/{WEEK}.json",
160 + "--analysis-source",
161 + source,
162 + "--analysis-model",
163 + model,
164 + "--validation-status",
165 + "passed" if gate_passed else "failed",
166 + "--gate-report",
167 + gate_report.as_posix(),
168 + "--output",
169 + manifest_path.as_posix(),
170 + ]
171 + if source == "copilot-cli":
172 + args.extend(["--preflight-report", preflight_report.as_posix()])
173 + publish_manifest.main(args)
174 finally:
175 os.chdir(previous_cwd)
176 return root / manifest_path
tests/test_publish_manifest.py
+126 -1
@@ -101,7 +101,45 @@ def write_gate_report(path: Path, *, passed: bool = True, errors: list[str] | No
101 )
102
103
104 -def create_args(base: Path, raw: Path, summary: Path, manifest: Path, *, source: str = "copilot-cli", model: str | None = "copilot-default", gate_report: Path | None = None, validation_status: str = "passed") -> list[str]:
104 +def write_preflight(path: Path, *, degraded: bool = False, publish_eligible: bool = True) -> None:
105 + path.parent.mkdir(parents=True, exist_ok=True)
106 + path.write_text(
107 + json.dumps(
108 + {
109 + "prompt_token_budget": 90000,
110 + "prompt_tokens": 1200,
111 + "prompt_bytes": 4800,
112 + "prompt_checksum_sha256": "a" * 64,
113 + "prompt_within_budget": True,
114 + "degraded": degraded,
115 + "publish_eligible": publish_eligible,
116 + "promotion_policy": (
117 + "normal-promotion"
118 + if not degraded
119 + else "staged/candidate-only by default; degraded compacted output requires an explicit future promotion policy."
120 + ),
121 + "degradation_reason": "Prompt was deterministically compacted." if degraded else None,
122 + "fallback_policy": "copilot-only",
123 + "components": [],
124 + "deterministic_slices": [],
125 + }
126 + ),
127 + encoding="utf-8",
128 + )
129 +
130 +
131 +def create_args(
132 + base: Path,
133 + raw: Path,
134 + summary: Path,
135 + manifest: Path,
136 + *,
137 + source: str = "copilot-cli",
138 + model: str | None = "copilot-default",
139 + gate_report: Path | None = None,
140 + validation_status: str = "passed",
141 + preflight: Path | None | bool = True,
142 +) -> list[str]:
143 args = [
144 "create",
145 "--week",
@@ -127,6 +165,12 @@ def create_args(base: Path, raw: Path, summary: Path, manifest: Path, *, source:
165 args.extend(["--analysis-model", model])
166 if gate_report is not None:
167 args.extend(["--gate-report", str(gate_report)])
168 + if preflight is True and source == "copilot-cli":
169 + preflight_path = manifest.parent / "diagnostics" / "analysis-preflight.json"
170 + write_preflight(preflight_path)
171 + args.extend(["--preflight-report", str(preflight_path)])
172 + elif isinstance(preflight, Path):
173 + args.extend(["--preflight-report", str(preflight)])
174 return args
175
176
@@ -160,6 +204,9 @@ class PublishManifestTests(unittest.TestCase):
204 payload = json.loads(manifest.read_text(encoding="utf-8"))
205 self.assertEqual(payload["schema_version"], "publish_eligibility_v1")
206 self.assertEqual(payload["analysis"]["ai_status"], "ai")
207 + self.assertEqual(payload["analysis"]["preflight"]["degraded"], False)
208 + self.assertEqual(payload["analysis"]["preflight"]["publish_eligible"], True)
209 + self.assertEqual(payload["analysis"]["preflight"]["promotion_policy"], "normal-promotion")
210 self.assertTrue(payload["promotion"]["eligible"])
211 self.assertEqual(payload["promotion"]["decision"], "promote")
212 self.assertRegex(payload["candidate"]["summary_sha256"], r"^[0-9a-f]{64}$")
@@ -192,6 +239,84 @@ class PublishManifestTests(unittest.TestCase):
239 with self.assertRaises(SystemExit):
240 assert_eligible_from_root(base, manifest)
241
242 + def test_copilot_ai_candidate_requires_preflight_for_promotion(self) -> None:
243 + tests_root = Path(__file__).resolve().parent
244 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
245 + base = Path(tmpdir)
246 + raw = base / "data/raw/2026-W21.json"
247 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
248 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
249 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
250 + write_raw(raw)
251 + write_summary(summary)
252 + write_gate_report(gate_report)
253 +
254 + publish_manifest.main(
255 + create_args(base, raw, summary, manifest, gate_report=gate_report, preflight=False)
256 + )
257 +
258 + payload = json.loads(manifest.read_text(encoding="utf-8"))
259 + self.assertEqual(payload["analysis"]["ai_status"], "ai")
260 + self.assertFalse(payload["promotion"]["eligible"])
261 + self.assertTrue(any("preflight report is required" in reason for reason in payload["promotion"]["reasons"]))
262 +
263 + def test_github_models_source_is_not_ai_publishable(self) -> None:
264 + tests_root = Path(__file__).resolve().parent
265 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
266 + base = Path(tmpdir)
267 + raw = base / "data/raw/2026-W21.json"
268 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
269 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
270 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
271 + write_raw(raw)
272 + write_summary(summary)
273 + write_gate_report(gate_report)
274 +
275 + publish_manifest.main(
276 + create_args(
277 + base,
278 + raw,
279 + summary,
280 + manifest,
281 + source="github-models",
282 + model="openai/gpt-4o",
283 + gate_report=gate_report,
284 + )
285 + )
286 +
287 + payload = json.loads(manifest.read_text(encoding="utf-8"))
288 + self.assertEqual(payload["analysis"]["ai_status"], "unknown")
289 + self.assertFalse(payload["promotion"]["eligible"])
290 + self.assertTrue(any("analysis source is not AI-publishable" in reason for reason in payload["promotion"]["reasons"]))
291 +
292 + def test_degraded_preflight_candidate_is_staged_only_by_default(self) -> None:
293 + tests_root = Path(__file__).resolve().parent
294 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
295 + base = Path(tmpdir)
296 + raw = base / "data/raw/2026-W21.json"
297 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
298 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
299 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
300 + preflight = base / "data/candidates/2026-W21/123456/diagnostics/analysis-preflight.json"
301 + write_raw(raw)
302 + write_summary(summary)
303 + write_gate_report(gate_report)
304 + write_preflight(preflight, degraded=True, publish_eligible=False)
305 +
306 + publish_manifest.main(
307 + create_args(base, raw, summary, manifest, gate_report=gate_report, preflight=preflight)
308 + )
309 +
310 + payload = json.loads(manifest.read_text(encoding="utf-8"))
311 + self.assertFalse(payload["promotion"]["eligible"])
312 + self.assertEqual(payload["promotion"]["decision"], "block")
313 + self.assertTrue(payload["analysis"]["preflight"]["degraded"])
314 + self.assertFalse(payload["analysis"]["preflight"]["publish_eligible"])
315 + self.assertIn("staged/candidate-only", payload["analysis"]["preflight"]["promotion_policy"])
316 + self.assertTrue(any("preflight degraded/compacted" in reason for reason in payload["promotion"]["reasons"]))
317 + with self.assertRaises(SystemExit):
318 + assert_eligible_from_root(base, manifest)
319 +
320 def test_copilot_candidate_without_explicit_model_uses_publishable_default(self) -> None:
321 tests_root = Path(__file__).resolve().parent
322 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: