fix: allow publish when post-compaction prompt is within budget + remove reskill (#513)

* fix: allow publish when post-compaction prompt is within budget + remove reskill step - publish_eligible now depends only on prompt_within_budget, not degraded flag - Remove degraded-as-blocker from load_preflight() and assert_eligible_from_root() - Remove reskill-check and reskill jobs from crawl-and-publish workflow - Update tests to match new behavior Fixes #510 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add test for degraded=True + publish_eligible=True promotable case Addresses review comment on PR #513: covers the post-compaction within-budget scenario where degraded candidates remain promotable. 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 16, 2026 at 19:35 UTC 1e7a23f7a94d38261bc62fe981b48902765c95f0
6 files changed +41 -249
.github/workflows/crawl-and-publish.yml
-186
@@ -1350,189 +1350,3 @@ jobs:
1350 --body "$BODY"
1351 fi
1352
1353 - reskill-check:
1354 - needs: [crawl]
1355 - runs-on: ubuntu-latest
1356 - outputs:
1357 - should_reskill: ${{ steps.check.outputs.reskill }}
1358 -
1359 - steps:
1360 - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
1361 - with:
1362 - fetch-depth: 0
1363 - ref: publish
1364 - persist-credentials: false
1365 - continue-on-error: true
1366 -
1367 - - name: Fallback to default branch for counter
1368 - env:
1369 - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1370 - run: |
1371 - if [ ! -f .squad/run-counter.txt ]; then
1372 - git fetch origin "$DEFAULT_BRANCH" 2>/dev/null || true
1373 - git checkout "origin/$DEFAULT_BRANCH" -- .squad/run-counter.txt 2>/dev/null || true
1374 - fi
1375 -
1376 - - name: Check reskill trigger
1377 - id: check
1378 - run: |
1379 - COUNTER=$(cat .squad/run-counter.txt 2>/dev/null || echo 0)
1380 - if [ $((COUNTER % 5)) -eq 0 ] && [ "$COUNTER" -gt 0 ]; then
1381 - echo "reskill=true" >> $GITHUB_OUTPUT
1382 - echo "🔄 Reskill triggered at run #$COUNTER"
1383 - else
1384 - echo "reskill=false" >> $GITHUB_OUTPUT
1385 - echo "📊 Run #$COUNTER — next reskill at run #$(( (COUNTER/5 + 1) * 5 ))"
1386 - fi
1387 -
1388 - reskill:
1389 - needs: [reskill-check]
1390 - if: needs.reskill-check.outputs.should_reskill == 'true'
1391 - runs-on: ubuntu-latest
1392 - permissions:
1393 - contents: write
1394 -
1395 - steps:
1396 - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # zizmor: ignore[artipacked] reskill job pushes to the publish branch; checkout token is reused by a later git push
1397 - with:
1398 - fetch-depth: 0
1399 - ref: publish
1400 - continue-on-error: true
1401 -
1402 - - name: Fallback to default branch
1403 - env:
1404 - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1405 - run: |
1406 - if [ ! -d .squad ]; then
1407 - git fetch origin "$DEFAULT_BRANCH"
1408 - git checkout "origin/$DEFAULT_BRANCH" -- . 2>/dev/null || true
1409 - fi
1410 -
1411 - - name: Set up Node
1412 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
1413 - with:
1414 - node-version: '24'
1415 -
1416 - - name: Set up Python
1417 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
1418 - with:
1419 - python-version: '3.12'
1420 -
1421 - - name: Install Copilot CLI
1422 - run: npm install -g @github/copilot
1423 -
1424 - - name: Run reskill
1425 - env:
1426 - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1427 - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GH_TOKEN }}
1428 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1429 - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1430 - run: |
1431 - set -euo pipefail
1432 - git config user.name "github-actions[bot]"
1433 - git config user.email "github-actions[bot]@users.noreply.github.com"
1434 - COUNTER=$(cat .squad/run-counter.txt 2>/dev/null || echo 0)
1435 - CURRENT_DATETIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1436 - WEEK=$(date -u +%G-W%V)
1437 - RESKILL_OUTPUT=".squad/reskill/${WEEK}.md"
1438 - RESKILL_PROMPT=".squad/reskill/current-prompt.md"
1439 - mkdir -p .squad/skills .squad/reskill data/metrics
1440 -
1441 - sanitize_agent_output() {
1442 - python3 scripts/sanitize_agent_output.py --path "$1"
1443 - }
1444 -
1445 - # Primary path: Copilot CLI with agent identity
1446 - RESKILL_SOURCE="copilot-cli"
1447 - RESKILL_MODEL="copilot-default"
1448 - python3 scripts/reskill.py --current-datetime "$CURRENT_DATETIME" --output "$RESKILL_OUTPUT" --prompt-output "$RESKILL_PROMPT" --print-prompt > "$RESKILL_PROMPT" || true
1449 -
1450 - # Intentionally rely on Copilot CLI's default model so CI follows the platform-supported default.
1451 - RESKILL_FAILURE_CLASS=""
1452 - if command -v copilot >/dev/null 2>&1; then
1453 - set +e
1454 - copilot \
1455 - --agent weekly-analysis \
1456 - -p "Read the file at ${RESKILL_PROMPT}. Write the complete reskill markdown to ${RESKILL_OUTPUT}. The run is incomplete until ${RESKILL_OUTPUT} exists and is non-empty. Do not delegate. Do not spawn sub-agents. Do not emit commentary." \
1457 - -s \
1458 - --no-ask-user \
1459 - --allow-tool=read \
1460 - --allow-tool=write \
1461 - > /dev/null
1462 - COPILOT_STATUS=$?
1463 - set -e
1464 - if [ "$COPILOT_STATUS" -eq 0 ] && test -s "$RESKILL_OUTPUT"; then
1465 - echo "✅ Reskill via focused analysis agent"
1466 - else
1467 - if [ "$COPILOT_STATUS" -eq 0 ]; then
1468 - RESKILL_FAILURE_CLASS="writer_contract_failure"
1469 - echo "::warning::Reskill writer contract failed: ${RESKILL_OUTPUT} missing or empty after Copilot completed."
1470 - else
1471 - RESKILL_FAILURE_CLASS="copilot_cli_failure"
1472 - echo "::warning::Reskill Copilot CLI invocation failed with exit code ${COPILOT_STATUS}."
1473 - fi
1474 - RESKILL_SOURCE="none"
1475 - RESKILL_MODEL="none"
1476 - rm -f "$RESKILL_PROMPT"
1477 - echo "Copilot CLI reskill failed; no GitHub Models/OpenAI reskill fallback is configured. Writing placeholder trigger log."
1478 - {
1479 - echo "Reskill triggered at run #$COUNTER ($CURRENT_DATETIME)"
1480 - echo "Failure class: ${RESKILL_FAILURE_CLASS}"
1481 - } >> .squad/reskill/trigger-log.txt
1482 - fi
1483 - else
1484 - RESKILL_FAILURE_CLASS="copilot_inaccessible"
1485 - RESKILL_SOURCE="none"
1486 - RESKILL_MODEL="none"
1487 - rm -f "$RESKILL_PROMPT"
1488 - echo "Copilot CLI reskill failed; no GitHub Models/OpenAI reskill fallback is configured. Writing placeholder trigger log."
1489 - {
1490 - echo "Reskill triggered at run #$COUNTER ($CURRENT_DATETIME)"
1491 - echo "Failure class: ${RESKILL_FAILURE_CLASS}"
1492 - } >> .squad/reskill/trigger-log.txt
1493 - fi
1494 -
1495 - if [ "$RESKILL_SOURCE" != "none" ]; then
1496 - sanitize_agent_output "$RESKILL_OUTPUT"
1497 - fi
1498 -
1499 - if [ "$RESKILL_SOURCE" != "none" ]; then
1500 - API_RESPONSE_ARGS=""
1501 - if [ -f "data/metrics/reskill-api-response.json" ]; then
1502 - API_RESPONSE_ARGS="--api-response data/metrics/reskill-api-response.json"
1503 - fi
1504 - python3 scripts/track_token_usage.py \
1505 - --stage reskill \
1506 - --source "$RESKILL_SOURCE" \
1507 - --model "$RESKILL_MODEL" \
1508 - --current-datetime "$CURRENT_DATETIME" \
1509 - --week "$WEEK" \
1510 - --prompt-file "$RESKILL_PROMPT" \
1511 - --output-file "$RESKILL_OUTPUT" \
1512 - $API_RESPONSE_ARGS
1513 - rm -f "$RESKILL_PROMPT"
1514 - echo "🔄 Reskill report generated for run #$COUNTER"
1515 - fi
1516 -
1517 - if ! git status --short -- .squad data/metrics | grep -q .; then
1518 - echo "No .squad or token usage changes to commit."
1519 - exit 0
1520 - fi
1521 -
1522 - cp -r .squad squad-state-backup
1523 - cp -r data/metrics reskill-metrics-backup
1524 - DATA_BRANCH="publish"
1525 - if git fetch origin "$DATA_BRANCH" 2>/dev/null; then
1526 - git checkout -f -B "$DATA_BRANCH" "origin/$DATA_BRANCH"
1527 - else
1528 - git fetch origin "$DEFAULT_BRANCH"
1529 - git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
1530 - fi
1531 - cp -r squad-state-backup/* .squad/ 2>/dev/null || true
1532 - mkdir -p data/metrics
1533 - cp -r reskill-metrics-backup/* data/metrics/ 2>/dev/null || true
1534 - rm -rf squad-state-backup reskill-metrics-backup
1535 - git add .squad/ data/metrics/
1536 - git diff --cached --quiet && exit 0
1537 - git commit -m "chore: reskill state update [run #${GITHUB_RUN_ID}]"
1538 - git push origin "$DATA_BRANCH"
scripts/analyze_fallback.py
+3 -3
@@ -957,11 +957,11 @@ def _build_prompt(
957 },
958 prompt_within_budget=prompt_within_budget,
959 degraded=degraded,
960 - publish_eligible=prompt_within_budget and not degraded,
960 + publish_eligible=prompt_within_budget,
961 promotion_policy=(
962 "normal-promotion"
963 - if not degraded
964 - else "staged/candidate-only by default; degraded compacted output requires an explicit future promotion policy."
963 + if prompt_within_budget
964 + else "staged/candidate-only by default; prompt exceeds token budget."
965 ),
966 degradation_reason=degradation_reason,
967 fallback_policy=(
scripts/publish_manifest.py
-6
@@ -125,10 +125,6 @@ def load_preflight(path: Path | None, *, required: bool = False) -> tuple[dict[s
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 - )
128 if payload.get("publish_eligible") is not True:
129 reasons.append("preflight report marks candidate as publish-ineligible")
130 return payload, reasons
@@ -715,8 +711,6 @@ def assert_eligible(args: argparse.Namespace) -> int:
711 preflight = analysis.get("preflight")
712 if not isinstance(preflight, dict) or preflight.get("publish_eligible") is not True:
713 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.")
714 validation = payload.get("validation")
715 gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
716 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
+5 -4
@@ -412,13 +412,14 @@ class AnalyzeFallbackTests(unittest.TestCase):
412 report = json.loads(report_path.read_text(encoding="utf-8"))
413 self.assertEqual(exit_code, 0)
414 self.assertTrue(report["degraded"])
415 - self.assertFalse(report["publish_eligible"])
416 - self.assertIn("staged/candidate-only", report["promotion_policy"])
415 + # Post-compaction prompt is within budget, so publish is eligible
416 + self.assertTrue(report["publish_eligible"])
417 + self.assertEqual("normal-promotion", report["promotion_policy"])
418 self.assertIn("compacted", report["degradation_reason"])
419 report_markdown = report_md_path.read_text(encoding="utf-8")
420 self.assertIn("Degraded/compacted: `true`", report_markdown)
420 - self.assertIn("Publish eligible: `false`", report_markdown)
421 - self.assertIn("staged/candidate-only", report_markdown)
421 + self.assertIn("Publish eligible: `true`", report_markdown)
422 + self.assertIn("normal-promotion", report_markdown)
423 components = {component["name"]: component for component in report["components"]}
424 self.assertIn("compacted to top", components["new_repos"]["compaction_decision"])
425 self.assertIn("compacted to top", components["trending_repos"]["compaction_decision"])
tests/test_pipeline.py
+5 -49
@@ -231,57 +231,13 @@ class WorkflowConfigTests(unittest.TestCase):
231 self.assertIn("UNTIL=$(date -u +%Y-%m-%d)", run_script)
232 self.assertIn('--until "$UNTIL"', run_script)
233
234 - def test_crawl_workflow_defines_reskill_jobs(self) -> None:
234 + def test_crawl_workflow_defines_analyze_job(self) -> None:
235 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
236 workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
237 -
238 - self.assertIn("reskill-check", workflow["jobs"])
239 - reskill_check = workflow["jobs"]["reskill-check"]
240 - self.assertEqual(reskill_check["needs"], ["crawl"])
241 -
242 - self.assertIn("reskill", workflow["jobs"])
243 - reskill = workflow["jobs"]["reskill"]
244 - self.assertEqual(reskill["needs"], ["reskill-check"])
245 - self.assertIn("needs.reskill-check.outputs.should_reskill", reskill["if"])
246 -
247 - check_step = next((s for s in reskill_check["steps"] if s.get("name") == "Check reskill trigger"), None)
248 - self.assertIsNotNone(check_step)
249 - check_run = check_step["run"]
250 - self.assertIn("reskill=true", check_run)
251 - self.assertIn("$GITHUB_OUTPUT", check_run)
252 -
253 - install_step = next((s for s in reskill["steps"] if s.get("name") == "Install Copilot CLI"), None)
254 - self.assertIsNotNone(install_step)
255 - self.assertEqual(install_step["run"], "npm install -g @github/copilot")
256 - self.assertNotIn("continue-on-error", install_step)
257 -
258 - reskill_step = next((s for s in reskill["steps"] if s.get("name") == "Run reskill"), None)
259 - self.assertIsNotNone(reskill_step)
260 - self.assertNotIn("GITHUB_MODELS_MODEL", workflow["env"])
261 - self.assertEqual(reskill_step["env"]["COPILOT_GITHUB_TOKEN"], "${{ secrets.COPILOT_GH_TOKEN }}")
262 - reskill_run = reskill_step["run"]
263 - self.assertIn("python3 scripts/reskill.py --current-datetime", reskill_run)
264 - self.assertIn("--prompt-output", reskill_run)
265 - self.assertIn("python3 scripts/track_token_usage.py", reskill_run)
266 - self.assertIn('RESKILL_MODEL="copilot-default"', reskill_run)
267 - self.assertNotIn("--model claude-sonnet-4", reskill_run)
268 - self.assertIn("mkdir -p .squad/skills .squad/reskill", reskill_run)
269 - self.assertIn("data/metrics", reskill_run)
270 - self.assertIn("trigger-log.txt", reskill_run)
271 - self.assertIn("git add .squad/", reskill_run)
272 - self.assertIn("data/metrics/", reskill_run)
273 - self.assertIn("no GitHub Models/OpenAI reskill fallback", reskill_run)
274 - self.assertNotIn("${GITHUB_MODELS_MODEL}", reskill_run)
275 - self.assertNotIn('RESKILL_SOURCE="github-models"', reskill_run)
276 - self.assertNotIn("used GitHub Models API fallback", reskill_run)
277 - self.assertIn("--agent weekly-analysis", reskill_run)
278 - self.assertIn('Read the file at ${RESKILL_PROMPT}. Write the complete reskill markdown to ${RESKILL_OUTPUT}.', reskill_run)
279 - self.assertIn('test -s "$RESKILL_OUTPUT"', reskill_run)
280 - self.assertIn('RESKILL_FAILURE_CLASS="writer_contract_failure"', reskill_run)
281 - self.assertNotIn("--allow-tool=glob", reskill_run)
282 - self.assertNotIn("--allow-tool=grep", reskill_run)
283 - # Prompt is written to a well-known path, not a temp file
284 - self.assertIn('RESKILL_PROMPT=".squad/reskill/current-prompt.md"', reskill_run)
237 +
238 + # Reskill jobs have been removed (analysis uses plain copilot-cli)
239 + self.assertNotIn("reskill-check", workflow["jobs"])
240 + self.assertNotIn("reskill", workflow["jobs"])
241
242 analyze = workflow["jobs"]["analyze"]
243 preflight_step = next((s for s in analyze["steps"] if s.get("name") == "Render and preflight analysis prompt"), None)
tests/test_publish_manifest.py
+28 -1
@@ -313,10 +313,37 @@ class PublishManifestTests(unittest.TestCase):
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"]))
316 + self.assertTrue(any("publish-ineligible" in reason for reason in payload["promotion"]["reasons"]))
317 with self.assertRaises(SystemExit):
318 assert_eligible_from_root(base, manifest)
319
320 + def test_degraded_but_publish_eligible_candidate_is_promotable(self) -> None:
321 + """Post-compaction within budget: degraded=True + publish_eligible=True is promotable."""
322 + tests_root = Path(__file__).resolve().parent
323 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
324 + base = Path(tmpdir)
325 + raw = base / "data/raw/2026-W21.json"
326 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
327 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
328 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
329 + preflight = base / "data/candidates/2026-W21/123456/diagnostics/analysis-preflight.json"
330 + write_raw(raw)
331 + write_summary(summary)
332 + write_gate_report(gate_report)
333 + write_preflight(preflight, degraded=True, publish_eligible=True)
334 +
335 + publish_manifest.main(
336 + create_args(base, raw, summary, manifest, gate_report=gate_report, preflight=preflight)
337 + )
338 +
339 + payload = json.loads(manifest.read_text(encoding="utf-8"))
340 + self.assertTrue(payload["promotion"]["eligible"])
341 + self.assertEqual(payload["promotion"]["decision"], "promote")
342 + self.assertTrue(payload["analysis"]["preflight"]["degraded"])
343 + self.assertTrue(payload["analysis"]["preflight"]["publish_eligible"])
344 + # Should not raise — degraded but eligible means promotable
345 + assert_eligible_from_root(base, manifest)
346 +
347 def test_copilot_candidate_without_explicit_model_uses_publishable_default(self) -> None:
348 tests_root = Path(__file__).resolve().parent
349 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: