Add run counter persistence and every-fifth-run reskill trigger (#40)

* Add run counter persistence and reskill trigger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add reskill retrospective tooling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address all 4 Copilot review comments on PR #40 - Fix git stash error handling: Use git stash create/store instead of || true - Fix counter increments on no-op crawls: Check git diff before incrementing - Fix trigger-log.txt persistence: Commit and push log file to repository - Fix test fragility: Use YAML parsing instead of raw substring matching Addresses review comments: - Line 133: Properly distinguish real stash errors from 'nothing to stash' - Line 138: Gate counter increment on actual data changes - Line 500: Create persistent audit trail of reskill triggers - Line 167: Make tests robust to YAML formatting changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 18, 2026 at 15:43 UTC 66d827ff8816a276fed999b4780f53a84d72b3ea
18 files changed +1006 -21
.github/workflows/crawl-and-publish.yml
+75 -11
@@ -117,21 +117,31 @@ jobs:
117 env:
118 DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
119 run: |
120 + set -euo pipefail
121 git config user.name "github-actions[bot]"
122 git config user.email "github-actions[bot]@users.noreply.github.com"
122 - if ! git status --short -- data/raw data/snapshots | grep -q .; then
123 - echo "No crawl data changes to commit."
124 - exit 0
125 - fi
126 - git stash push --include-untracked --message crawl-data -- data/raw data/snapshots
123 + STASH_REF=$(git stash create --include-untracked --message crawl-data -- data/raw data/snapshots) || STASH_REF=""
124 git fetch origin "$DEFAULT_BRANCH"
125 git checkout -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
129 - git stash pop || {
130 - echo "Failed to reapply crawl data after syncing $DEFAULT_BRANCH."
131 - exit 1
132 - }
133 - git add data/raw/ data/snapshots/
134 - git commit -m "data: weekly crawl $(date +%Y-W%V)"
126 + if [ -n "$STASH_REF" ]; then
127 + git stash store "$STASH_REF" || {
128 + echo "Failed to store stash."
129 + exit 1
130 + }
131 + git stash pop || {
132 + echo "Failed to reapply crawl data after syncing $DEFAULT_BRANCH."
133 + exit 1
134 + }
135 + fi
136 + if git diff --quiet -- data/raw data/snapshots; then
137 + echo "No changes to data/raw or data/snapshots. Skipping counter increment."
138 + exit 0
139 + fi
140 + COUNTER=$(cat .squad/run-counter.txt 2>/dev/null || echo 0)
141 + COUNTER=$((COUNTER + 1))
142 + printf '%s\n' "$COUNTER" > .squad/run-counter.txt
143 + git add data/raw/ data/snapshots/ .squad/run-counter.txt
144 + git diff --cached --quiet || git commit -m "data: weekly crawl $(date +%Y-W%V)"
145 git push origin "HEAD:$DEFAULT_BRANCH" || {
146 echo "Push failed after syncing with $DEFAULT_BRANCH."
147 exit 1
@@ -452,3 +462,57 @@ jobs:
462 WEEK=$(basename "$SUMMARY_FILE" | sed 's/-summary.md//')
463
464 gh release create "week-${WEEK}" --title "Week ${WEEK} — Tech Trends Summary" --notes-file "$SUMMARY_FILE" --latest
465 +
466 + reskill-check:
467 + needs: [crawl]
468 + runs-on: ubuntu-latest
469 + outputs:
470 + should_reskill: ${{ steps.check.outputs.reskill }}
471 +
472 + steps:
473 + - uses: actions/checkout@v4
474 + with:
475 + fetch-depth: 0
476 + ref: ${{ github.event.repository.default_branch }}
477 +
478 + - name: Check reskill trigger
479 + id: check
480 + run: |
481 + COUNTER=$(cat .squad/run-counter.txt 2>/dev/null || echo 0)
482 + if [ $((COUNTER % 5)) -eq 0 ] && [ "$COUNTER" -gt 0 ]; then
483 + echo "reskill=true" >> $GITHUB_OUTPUT
484 + echo "🔄 Reskill triggered at run #$COUNTER"
485 + else
486 + echo "reskill=false" >> $GITHUB_OUTPUT
487 + echo "📊 Run #$COUNTER — next reskill at run #$(( (COUNTER/5 + 1) * 5 ))"
488 + fi
489 +
490 + reskill:
491 + needs: [reskill-check]
492 + if: needs.reskill-check.outputs.should_reskill == 'true'
493 + runs-on: ubuntu-latest
494 +
495 + steps:
496 + - uses: actions/checkout@v4
497 + with:
498 + fetch-depth: 0
499 + ref: ${{ github.event.repository.default_branch }}
500 +
501 + - name: Run placeholder reskill
502 + env:
503 + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
504 + run: |
505 + set -euo pipefail
506 + git config user.name "github-actions[bot]"
507 + git config user.email "github-actions[bot]@users.noreply.github.com"
508 + COUNTER=$(cat .squad/run-counter.txt 2>/dev/null || echo 0)
509 + mkdir -p .squad/skills .squad/reskill
510 + echo "Reskill triggered at run #$COUNTER ($(date -Iseconds))" >> .squad/reskill/trigger-log.txt
511 + git add .squad/reskill/trigger-log.txt
512 + git diff --cached --quiet || {
513 + git commit -m "audit: reskill triggered at run #$COUNTER"
514 + git push origin "HEAD:$DEFAULT_BRANCH" || {
515 + echo "Warning: Failed to push trigger log to $DEFAULT_BRANCH, but continuing."
516 + }
517 + }
518 + echo "🔄 Reskill placeholder ran at run #$COUNTER"
.squad/agents/bender/history.md
+2
@@ -24,3 +24,5 @@
24 - **2026-05-18T10:27:35.339+02:00:** The crawler now uses `GET /search/repositories` for both `created:>{last_week_date} stars:>50` and `pushed:>{last_week_date} stars:>50`, comparing current stars against the most recent prior `data/raw/*.json` snapshot when available to estimate weekly star gains. It authenticates with `GITHUB_TOKEN`, paginates up to the GitHub Search API's 1,000-result ceiling, caches README checks in-process, applies exponential backoff with jitter for rate limits, and skips repos whose README lookup is blocked by org SAML enforcement.
25 - **2026-05-18T10:59:10Z:** Issue #5 complete. Commit fb14275 (209 new repos, 215 trending in data/raw/2026-W21.json). Ready for Issue #6+. User directive: all future work follows branch → PR → Review → Merge workflow (no direct commits to main).
26 - **2026-05-18T10:50:21Z:** PR #27 (Issue #8 crawl workflow) review complete. All 7 Copilot findings addressed (abb2a80). Workflow structure: restore `data/cache/` artifact, run `scripts/crawl.py`, upload `crawl-output` + new cache. Permissions `actions: read` + `contents: write`. Ready for merge. Downstream phases can depend on cache artifacts.
27 +- **2026-05-18T15:22:25.067+02:00:** Issue #15 should increment `.squad/run-counter.txt` inside the `crawl` commit step after syncing the default branch, so the workflow reads the latest persisted counter, writes the incremented value atomically, and commits it alongside crawl artifacts.
28 +- **2026-05-18T15:22:25.067+02:00:** The reskill trigger can stay lightweight for now: a `reskill-check` job only needs the persisted counter from `crawl`, and a gated placeholder `reskill` job can scaffold `.squad/skills/` and `.squad/reskill/` until Issue #14 adds real retrospective outputs and `.squad/` persistence.
.squad/agents/farnsworth/history.md
+1
@@ -19,3 +19,4 @@
19 - **2026-05-18T12:07:20.778+02:00:** The analyzer contract should be a superset of Amy’s weekly page frontmatter plus Leela’s `quality_score` gate, so one analyzed artifact can satisfy both editorial review and generator input.
20 - **2026-05-18T12:07:20.778+02:00:** Keep the reader-facing weekly summary in five stable H2 sections, but require labeled `Signal`, `Noise`, and `Gaps` subsections so the editorial lens remains explicit and machine-checkable.
21 - **2026-05-18T13:20:07.067+02:00:** Weekly analysis prose should render repo mentions as explicit GitHub markdown links, and the current raw crawl artifact exposes those repo page URLs under `url` rather than `html_url`, so analyzer/generator prompts should require link formatting without assuming a different field name.
22 +- **2026-05-18T15:22:25.067+02:00:** The learning loop only becomes real when learned state is both persisted and injected back into the next weekly prompt. Reskill reports need recent summaries, snapshot hindsight, and quality trend context; the weekly analyzer must read `wisdom.md` plus `.squad/skills/` at prompt-render time so lessons change future judgment instead of sitting idle.
.squad/decisions/inbox/bender-run-counter.md new
+26
@@ -0,0 +1,26 @@
1 +# Bender Decision Inbox — Run Counter & Reskill Trigger
2 +
3 +- **Date:** 2026-05-18T15:22:25.067+02:00
4 +- **Author:** Bender
5 +- **Issue:** #15 — Add run counter persistence and every-fifth-run reskill trigger
6 +
7 +## Context
8 +
9 +The weekly crawl workflow already serializes runs with `concurrency`, but the learning audit found two missing pieces: there was no persisted `.squad/run-counter.txt`, and no workflow job checked that counter to trigger the every-5th-run reskill cycle.
10 +
11 +## Decision
12 +
13 +1. Create `.squad/run-counter.txt` in the repository, initialized to `0`.
14 +2. Increment the counter inside the `crawl` job's git commit step **after** syncing the default branch, then commit `.squad/run-counter.txt` together with `data/raw/` and `data/snapshots/`.
15 +3. Add a dedicated `reskill-check` job that reads the persisted counter and exposes `should_reskill` for downstream jobs.
16 +4. Add a gated placeholder `reskill` job that logs the trigger and scaffolds `.squad/skills/` and `.squad/reskill/` until Issue #14 adds the full retrospective implementation.
17 +
18 +## Why
19 +
20 +- Reading the counter only after syncing `origin/main` keeps the increment tied to the latest persisted state.
21 +- Committing `.squad/run-counter.txt` in the same crawl commit ensures the trigger survives between weekly runs.
22 +- Splitting `reskill-check` from `reskill` keeps the trigger logic auditable and makes the future reskill implementation easier to extend.
23 +
24 +## Follow-up
25 +
26 +- Issue #14 should add `.squad/` persistence for reskill outputs and the actual retrospective prompt/output flow.
.squad/decisions/inbox/farnsworth-reskill.md new
+23
@@ -0,0 +1,23 @@
1 +# Farnsworth Reskill Workflow Decisions
2 +
3 +- **Date:** 2026-05-18T15:22:25.067+02:00
4 +- **Issue:** #14
5 +- **Scope:** Reskill retrospective, learned-state injection, and quality trend tracking
6 +
7 +## Proposed decisions
8 +
9 +1. **Reskill context should be assembled from the latest analyzer evidence, not generic squad history alone.**
10 + - Inputs: last up to five `data/analyzed/*-summary.md` files, matching `data/snapshots/` hindsight when available, current `wisdom.md`, learned skills, and a quality trend report.
11 + - Why: this gives the retrospective something concrete to calibrate against and closes gaps G3-G7, G11, and G12.
12 +
13 +2. **Learned state must flow back into the weekly analyzer prompt.**
14 + - The analyze job should inject `.squad/identity/wisdom.md` into `{{WISDOM}}` and concatenated markdown from `.squad/skills/` into `{{SKILLS}}` before calling Copilot CLI or the GitHub Models fallback.
15 + - Why: without prompt injection, learning artifacts exist but never influence future analysis.
16 +
17 +3. **Quality trend tracking should be a first-class reskill input.**
18 + - `scripts/track_quality.py` should read `quality_score` from analyzed summaries and produce a markdown trend report for retrospective review.
19 + - Why: the squad needs a lightweight longitudinal measure of whether editorial quality is improving.
20 +
21 +4. **Reskill outputs belong in persistent squad state.**
22 + - Keep `.squad/reskill/` for weekly retrospective reports and `.squad/skills/` for extracted reusable patterns, both committed to git.
23 + - Why: durable learning needs durable storage, not ephemeral workflow output.
.squad/identity/wisdom.md
+28 -2
@@ -1,5 +1,5 @@
1 ---
2 -last_updated: 2026-05-18T07:39:25.031Z
2 +last_updated: 2026-05-18T15:22:25.067+02:00
3 ---
4
5 # Team Wisdom
@@ -8,4 +8,30 @@ Reusable patterns and heuristics learned through work. NOT transcripts — each
8
9 ## Patterns
10
11 -<!-- Append entries below. Format: **Pattern:** description. **Context:** when it applies. -->
11 +## Signal Detection Patterns
12 +
13 +- **Practical utility beats novelty theater.** Treat repositories as signal when they clearly reduce workflow friction, solve recurring engineering pain, or make production work more trustworthy.
14 +- **Clustered movement matters more than one loud launch.** A single popular repo is not a trend; multiple repositories and topics pulling in the same direction usually signal durable ecosystem movement.
15 +- **Operational credibility is a strong positive signal.** Favor projects that show observability, maintenance discipline, packaging clarity, or workflow realism over broad autonomy claims.
16 +- **Research counts when it changes practice.** Research-heavy repos can be signal, but only when they point toward credible adoption, new workflows, or meaningful technical movement beyond demos.
17 +
18 +## Noise / Hype Detection Patterns
19 +
20 +- **Stars without deltas are popularity, not momentum.** Treat attention as directional when `stars_gained` or historical baselines are missing; do not overstate it as trend acceleration.
21 +- **Marketing-heavy wrappers are usually weak signal.** Thinly differentiated agent launches, clone products, and branding-first repos deserve skepticism unless the implementation meaningfully changes capability or cost.
22 +- **Exploit, bypass, and cheat churn distort the picture.** These repos may be active, but they are usually editorial noise unless they reveal a deeper defensive or ecosystem shift.
23 +- **If the promise sounds bigger than the evidence, call it hype.** Strong claims without technical differentiation, adoption evidence, or operational substance are noise until proven otherwise.
24 +
25 +## Gap Analysis Focus Areas
26 +
27 +- **Look for absent infrastructure around known pain.** Missing testing, observability, defensive security, maintenance, or reliability tooling is often more important than another crowded launch category.
28 +- **Name what should exist but does not.** Useful gap analysis points to concrete missing categories, not generic wishes for “more innovation.”
29 +- **Track ecosystem balance, not just heat.** When one area dominates attention, check which adjacent needs are being ignored or underfunded.
30 +- **Missing baselines are themselves a gap.** If the pipeline lacks enough historical data to validate momentum or hindsight, say so explicitly.
31 +
32 +## Trend Detection Approaches
33 +
34 +- **Compare week-to-week whenever possible.** Look for continuity, acceleration, reversal, or broadening rather than treating each weekly crawl as isolated.
35 +- **Use topic counts as supporting evidence only.** `signals.top_topics` can confirm a pattern, but topic frequency alone does not prove significance.
36 +- **Prefer repeated technical themes over brand repetition.** Trend calls should come from recurring problem/solution patterns, not from the same large projects staying visible.
37 +- **Be explicit about uncertainty.** Honest caveats improve trust; if momentum data or historical context is thin, the analysis should say so rather than pretend precision.
.squad/reskill/.gitkeep
.squad/run-counter.txt new
+1
@@ -0,0 +1 @@
1 +0
.squad/skills/.gitkeep
prompts/analyze-weekly.md
+19 -1
@@ -25,6 +25,23 @@ Use this only if it is provided. If it is missing, unavailable, or empty, say so
25 {{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}
26 ```
27
28 +## Learned context
29 +
30 +The analyze job must resolve both learned-state placeholders before invoking Copilot CLI or the GitHub Models fallback.
31 +
32 +1. Read `.squad/identity/wisdom.md` and inject its current contents into `{{WISDOM}}`.
33 +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}}`.
34 +3. If either source is missing or empty, inject a short explicit note rather than leaving the placeholder unresolved.
35 +4. Treat learned context as guidance that sharpens judgment, not as permission to ignore the current week's evidence.
36 +
37 +### Wisdom
38 +
39 +{{WISDOM}}
40 +
41 +### Skills
42 +
43 +{{SKILLS}}
44 +
45 ## Objective
46
47 Write the full contents of `{{OUTPUT_PATH}}` as markdown with YAML frontmatter. The file must conform to the Output Contract in `docs/analysis-spec.md` exactly.
@@ -108,7 +125,8 @@ Be critical, selective, and opinionated.
125 4. Name one or more overhyped or low-signal patterns.
126 5. Identify concrete gaps or absences.
127 6. Compare with the previous week if a previous summary was provided.
111 -7. Produce a concise, readable editorial summary that a technical reader would actually trust.
128 +7. Apply relevant wisdom and skills where they clarify the call, but overrule them when the raw evidence says they do not fit this week.
129 +8. Produce a concise, readable editorial summary that a technical reader would actually trust.
130
131 ## Output template
132
prompts/reskill.md new
+113
@@ -0,0 +1,113 @@
1 +# Reskill Retrospective Prompt Template
2 +
3 +You are Farnsworth, running SquadScope's reskill cycle.
4 +
5 +Your job is to review recent analysis output, calibrate the analyst's judgment, and produce a structured retrospective that improves the next analysis cycle.
6 +
7 +## Inputs
8 +
9 +- Current datetime: `{{CURRENT_DATETIME}}`
10 +- Output path: `{{OUTPUT_PATH}}`
11 +
12 +### Current wisdom
13 +
14 +{{WISDOM}}
15 +
16 +### Current skills
17 +
18 +{{SKILLS}}
19 +
20 +### Quality trend report
21 +
22 +{{QUALITY_TREND}}
23 +
24 +### Recent analysis summaries (last 5 weeks, oldest to newest)
25 +
26 +{{RECENT_ANALYSES}}
27 +
28 +### Snapshot hindsight context
29 +
30 +{{SNAPSHOT_CONTEXT}}
31 +
32 +## Objective
33 +
34 +Write the full contents of `{{OUTPUT_PATH}}` as a markdown reskill report.
35 +
36 +## Required review method
37 +
38 +1. Review the last 5 weeks of analysis output from `data/analyzed/`.
39 +2. Compare what prior summaries labeled as **Signal**, **Noise**, and **Gaps**.
40 +3. Use snapshot data from `data/snapshots/` for hindsight validation where it exists. If it does not exist for a week, say so explicitly and avoid false certainty.
41 +4. Identify recurring blind spots, accuracy trends, topic coverage gaps, and places where the editorial lens is over- or under-reacting.
42 +5. Update wisdom heuristics by naming what should be kept, strengthened, or retired.
43 +6. Extract new reusable skills or patterns when a lesson is concrete enough to guide future analysis.
44 +7. Ground the retrospective in evidence from the actual summaries and snapshots, not in generic advice.
45 +
46 +## Output requirements
47 +
48 +- Output only the finished markdown report.
49 +- Be candid and specific.
50 +- Do not rewrite history; evaluate it.
51 +- Do not modify `data/raw/` or `data/analyzed/`.
52 +- When evidence is incomplete, call that out.
53 +
54 +## Required report structure
55 +
56 +```md
57 +# Reskill Report: YYYY-WNN
58 +
59 +- Date: {{CURRENT_DATETIME}}
60 +- Scope: Last up to 5 analyzed summaries with snapshot hindsight where available
61 +
62 +## Retrospective Summary
63 +
64 +A concise statement of what the analyst is getting right and where judgment is drifting.
65 +
66 +## Accuracy Review
67 +
68 +### Signal
69 +
70 +Assess which signal calls looked durable versus overstated.
71 +
72 +### Noise
73 +
74 +Assess which noise calls were accurate versus overly cynical or too soft.
75 +
76 +### Gaps
77 +
78 +Assess whether the missing-theme calls were useful, repetitive, or unsupported.
79 +
80 +## Recurring Blind Spots
81 +
82 +List repeated analyst failures or recurring uncertainty patterns.
83 +
84 +## Topic Coverage Gaps
85 +
86 +Describe areas the weekly summaries are still under-covering.
87 +
88 +## Quality Trend
89 +
90 +Interpret the `quality_score` trend over time and what it suggests.
91 +
92 +## Wisdom Updates
93 +
94 +### Keep
95 +
96 +Heuristics that still seem reliable.
97 +
98 +### Change
99 +
100 +Heuristics that need tightening or revision.
101 +
102 +### Add
103 +
104 +New heuristics to append to `wisdom.md`.
105 +
106 +## Skill Candidates
107 +
108 +List reusable skill or pattern candidates worth capturing under `.squad/skills/`.
109 +
110 +## Next-Cycle Adjustments
111 +
112 +Name the concrete changes the next weekly analysis should make.
113 +```
scripts/analyze_fallback.py
+50 -7
@@ -4,7 +4,6 @@ from __future__ import annotations
4 import argparse
5 import json
6 import os
7 -import sys
7 from pathlib import Path
8 from typing import Any
9 from urllib import error, request
@@ -12,6 +11,8 @@ from urllib import error, request
11 ROOT = Path(__file__).resolve().parent.parent
12 DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "analyze-weekly.md"
13 DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
14 +DEFAULT_WISDOM_FILE = ROOT / ".squad" / "identity" / "wisdom.md"
15 +DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills"
16 DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
17 DEFAULT_MODELS_MODEL = "openai/gpt-4.1"
18 DEFAULT_MODELS_TIMEOUT = 30
@@ -34,6 +35,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
35 default=DEFAULT_ANALYZED_DIR,
36 help="Directory containing prior weekly summaries.",
37 )
38 + parser.add_argument(
39 + "--wisdom-file",
40 + type=Path,
41 + default=DEFAULT_WISDOM_FILE,
42 + help="Path to the learned wisdom markdown file.",
43 + )
44 + parser.add_argument(
45 + "--skills-dir",
46 + type=Path,
47 + default=DEFAULT_SKILLS_DIR,
48 + help="Directory containing learned skill markdown files.",
49 + )
50 parser.add_argument(
51 "--print-prompt",
52 action="store_true",
@@ -58,6 +71,35 @@ def find_previous_summary(current_week: str, analyzed_dir: Path) -> Path | None:
71 return max(candidates, default=None)
72
73
74 +def render_wisdom(wisdom_file: Path) -> str:
75 + if not wisdom_file.exists():
76 + return "_No learned wisdom has been recorded yet._"
77 +
78 + content = wisdom_file.read_text(encoding="utf-8").strip()
79 + return content or "_No learned wisdom has been recorded yet._"
80 +
81 +
82 +def iter_skill_files(skills_dir: Path) -> list[Path]:
83 + if not skills_dir.exists():
84 + return []
85 + return sorted(path for path in skills_dir.rglob("*.md") if path.is_file())
86 +
87 +
88 +def render_skills(skills_dir: Path) -> str:
89 + skill_files = iter_skill_files(skills_dir)
90 + if not skill_files:
91 + return "_No learned skills have been extracted yet._"
92 +
93 + blocks = []
94 + for path in skill_files:
95 + relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
96 + content = path.read_text(encoding="utf-8").strip()
97 + if not content:
98 + continue
99 + blocks.append(f"--- Skill Source: {relative_path} ---\n{content}")
100 + return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
101 +
102 +
103 def render_prompt(
104 *,
105 prompt_template_path: Path,
@@ -65,6 +107,8 @@ def render_prompt(
107 output_path: Path,
108 current_datetime: str,
109 analyzed_dir: Path,
110 + wisdom_file: Path = DEFAULT_WISDOM_FILE,
111 + skills_dir: Path = DEFAULT_SKILLS_DIR,
112 ) -> str:
113 payload = load_json(raw_json_path)
114 current_week = payload["week"]
@@ -79,6 +123,8 @@ def render_prompt(
123 "{{PREVIOUS_SUMMARY_PATH_OR_NONE}}": str(previous_summary_path) if previous_summary_path else "None",
124 "{{RAW_JSON_CONTENT}}": raw_json_path.read_text(encoding="utf-8").strip(),
125 "{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}": previous_summary_content.strip(),
126 + "{{WISDOM}}": render_wisdom(wisdom_file),
127 + "{{SKILLS}}": render_skills(skills_dir),
128 }
129 for needle, value in replacements.items():
130 prompt = prompt.replace(needle, value)
@@ -123,12 +169,7 @@ def call_github_models(prompt: str) -> str:
169 timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
170 payload = {
171 "model": model,
126 - "messages": [
127 - {
128 - "role": "user",
129 - "content": prompt,
130 - }
131 - ],
172 + "messages": [{"role": "user", "content": prompt}],
173 "temperature": 0.3,
174 }
175 body = json.dumps(payload).encode("utf-8")
@@ -163,6 +204,8 @@ def main(argv: list[str] | None = None) -> int:
204 output_path=args.output,
205 current_datetime=args.current_datetime,
206 analyzed_dir=args.analyzed_dir,
207 + wisdom_file=args.wisdom_file,
208 + skills_dir=args.skills_dir,
209 )
210
211 if args.print_prompt:
scripts/reskill.py new
+289
@@ -0,0 +1,289 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import argparse
5 +import json
6 +import os
7 +import sys
8 +from datetime import UTC, datetime
9 +from pathlib import Path
10 +from typing import Any
11 +from urllib import error, request
12 +
13 +ROOT = Path(__file__).resolve().parent.parent
14 +if str(ROOT) not in sys.path:
15 + sys.path.insert(0, str(ROOT))
16 +
17 +from scripts import track_quality
18 +
19 +DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "reskill.md"
20 +DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
21 +DEFAULT_SNAPSHOTS_DIR = ROOT / "data" / "snapshots"
22 +DEFAULT_WISDOM_FILE = ROOT / ".squad" / "identity" / "wisdom.md"
23 +DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills"
24 +DEFAULT_REPORT_DIR = ROOT / ".squad" / "reskill"
25 +DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
26 +DEFAULT_MODELS_MODEL = "openai/gpt-4.1"
27 +DEFAULT_MODELS_TIMEOUT = 30
28 +
29 +
30 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
31 + parser = argparse.ArgumentParser(description="Run the SquadScope reskill retrospective.")
32 + parser.add_argument("--current-datetime", required=True, help="ISO-8601 timestamp for the reskill run.")
33 + parser.add_argument(
34 + "--prompt-template",
35 + type=Path,
36 + default=DEFAULT_PROMPT_TEMPLATE,
37 + help="Prompt template path (defaults to prompts/reskill.md).",
38 + )
39 + parser.add_argument(
40 + "--analyzed-dir",
41 + type=Path,
42 + default=DEFAULT_ANALYZED_DIR,
43 + help="Directory containing analyzed weekly summaries.",
44 + )
45 + parser.add_argument(
46 + "--snapshots-dir",
47 + type=Path,
48 + default=DEFAULT_SNAPSHOTS_DIR,
49 + help="Directory containing weekly snapshot JSON files.",
50 + )
51 + parser.add_argument(
52 + "--wisdom-file",
53 + type=Path,
54 + default=DEFAULT_WISDOM_FILE,
55 + help="Path to the learned wisdom markdown file.",
56 + )
57 + parser.add_argument(
58 + "--skills-dir",
59 + type=Path,
60 + default=DEFAULT_SKILLS_DIR,
61 + help="Directory containing learned skill markdown files.",
62 + )
63 + parser.add_argument(
64 + "--output",
65 + type=Path,
66 + help="Path to write the reskill report. Defaults to .squad/reskill/YYYY-WNN.md.",
67 + )
68 + parser.add_argument("--limit", type=int, default=5, help="Maximum number of analyzed summaries to include.")
69 + parser.add_argument(
70 + "--print-prompt",
71 + action="store_true",
72 + help="Render the prompt to stdout without calling GitHub Models.",
73 + )
74 + return parser.parse_args(argv)
75 +
76 +
77 +def parse_datetime(value: str) -> datetime:
78 + candidate = value.strip()
79 + if candidate.endswith("Z"):
80 + candidate = f"{candidate[:-1]}+00:00"
81 + parsed = datetime.fromisoformat(candidate)
82 + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
83 +
84 +
85 +def week_slug(value: datetime) -> str:
86 + year, week, _ = value.isocalendar()
87 + return f"{year}-W{week:02d}"
88 +
89 +
90 +def default_output_path(current_datetime: str) -> Path:
91 + return DEFAULT_REPORT_DIR / f"{week_slug(parse_datetime(current_datetime))}.md"
92 +
93 +
94 +def render_wisdom(wisdom_file: Path) -> str:
95 + if not wisdom_file.exists():
96 + return "_No learned wisdom has been recorded yet._"
97 + content = wisdom_file.read_text(encoding="utf-8").strip()
98 + return content or "_No learned wisdom has been recorded yet._"
99 +
100 +
101 +def render_skills(skills_dir: Path) -> str:
102 + if not skills_dir.exists():
103 + return "_No learned skills have been extracted yet._"
104 +
105 + skill_files = sorted(path for path in skills_dir.rglob("*.md") if path.is_file())
106 + if not skill_files:
107 + return "_No learned skills have been extracted yet._"
108 +
109 + blocks = []
110 + for path in skill_files:
111 + relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
112 + content = path.read_text(encoding="utf-8").strip()
113 + if content:
114 + blocks.append(f"--- Skill Source: {relative_path} ---\n{content}")
115 + return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
116 +
117 +
118 +def find_recent_summaries(analyzed_dir: Path, limit: int) -> list[Path]:
119 + summaries = sorted(analyzed_dir.glob("*-summary.md")) if analyzed_dir.exists() else []
120 + if limit <= 0:
121 + return summaries
122 + return summaries[-limit:]
123 +
124 +
125 +def render_recent_analyses(analyzed_dir: Path, limit: int) -> str:
126 + summaries = find_recent_summaries(analyzed_dir, limit)
127 + if not summaries:
128 + return "_No analyzed summaries are available yet._"
129 +
130 + blocks = []
131 + for path in summaries:
132 + relative_path = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
133 + blocks.append(f"--- Analysis Source: {relative_path} ---\n{path.read_text(encoding='utf-8').strip()}")
134 + return "\n\n".join(blocks)
135 +
136 +
137 +def snapshot_candidates(week: str, snapshots_dir: Path) -> list[Path]:
138 + if not snapshots_dir.exists():
139 + return []
140 + patterns = [f"{week}.json", f"{week}-*.json"]
141 + matches: list[Path] = []
142 + for pattern in patterns:
143 + matches.extend(sorted(path for path in snapshots_dir.glob(pattern) if path.is_file()))
144 + deduped = []
145 + seen: set[Path] = set()
146 + for path in matches:
147 + if path not in seen:
148 + deduped.append(path)
149 + seen.add(path)
150 + return deduped
151 +
152 +
153 +def render_snapshot_context(analyzed_dir: Path, snapshots_dir: Path, limit: int) -> str:
154 + summaries = find_recent_summaries(analyzed_dir, limit)
155 + if not summaries:
156 + return "_No analyzed summaries are available, so no snapshot hindsight can be matched yet._"
157 +
158 + blocks = []
159 + for summary_path in summaries:
160 + week = summary_path.name.removesuffix("-summary.md")
161 + matches = snapshot_candidates(week, snapshots_dir)
162 + if not matches:
163 + blocks.append(f"--- Snapshot Context: {week} ---\nNo snapshot data available for hindsight validation.")
164 + continue
165 + rendered_matches = []
166 + for snapshot_path in matches:
167 + relative_path = snapshot_path.relative_to(ROOT) if snapshot_path.is_relative_to(ROOT) else snapshot_path
168 + rendered_matches.append(f"File: {relative_path}\n{snapshot_path.read_text(encoding='utf-8').strip()}")
169 + blocks.append(f"--- Snapshot Context: {week} ---\n" + "\n\n".join(rendered_matches))
170 + return "\n\n".join(blocks)
171 +
172 +
173 +def render_prompt(
174 + *,
175 + prompt_template_path: Path,
176 + current_datetime: str,
177 + output_path: Path,
178 + analyzed_dir: Path,
179 + snapshots_dir: Path,
180 + wisdom_file: Path,
181 + skills_dir: Path,
182 + limit: int,
183 +) -> str:
184 + prompt = prompt_template_path.read_text(encoding="utf-8")
185 + replacements = {
186 + "{{CURRENT_DATETIME}}": current_datetime,
187 + "{{OUTPUT_PATH}}": str(output_path),
188 + "{{WISDOM}}": render_wisdom(wisdom_file),
189 + "{{SKILLS}}": render_skills(skills_dir),
190 + "{{QUALITY_TREND}}": track_quality.build_quality_report(analyzed_dir).strip(),
191 + "{{RECENT_ANALYSES}}": render_recent_analyses(analyzed_dir, limit),
192 + "{{SNAPSHOT_CONTEXT}}": render_snapshot_context(analyzed_dir, snapshots_dir, limit),
193 + }
194 + for needle, value in replacements.items():
195 + prompt = prompt.replace(needle, value)
196 + return prompt
197 +
198 +
199 +def extract_markdown(response_payload: dict[str, Any]) -> str:
200 + choices = response_payload.get("choices") or []
201 + if not choices:
202 + raise ValueError("GitHub Models response did not include any choices.")
203 +
204 + message = choices[0].get("message") or {}
205 + content = message.get("content")
206 +
207 + if isinstance(content, str):
208 + return content.strip() + "\n"
209 +
210 + if isinstance(content, list):
211 + parts: list[str] = []
212 + for item in content:
213 + if isinstance(item, dict):
214 + text = item.get("text") or item.get("output_text")
215 + if text:
216 + parts.append(text)
217 + if parts:
218 + return "\n".join(parts).strip() + "\n"
219 +
220 + text = choices[0].get("text")
221 + if isinstance(text, str) and text.strip():
222 + return text.strip() + "\n"
223 +
224 + raise ValueError("GitHub Models response did not contain markdown output.")
225 +
226 +
227 +def call_github_models(prompt: str) -> str:
228 + token = os.environ.get("GITHUB_TOKEN")
229 + if not token:
230 + raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.")
231 +
232 + endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
233 + model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL)
234 + timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
235 + payload = {
236 + "model": model,
237 + "messages": [{"role": "user", "content": prompt}],
238 + "temperature": 0.2,
239 + }
240 + body = json.dumps(payload).encode("utf-8")
241 + req = request.Request(
242 + endpoint,
243 + data=body,
244 + headers={
245 + "Authorization": f"Bearer {token}",
246 + "Content-Type": "application/json",
247 + "Accept": "application/json",
248 + },
249 + method="POST",
250 + )
251 +
252 + try:
253 + with request.urlopen(req, timeout=timeout) as response:
254 + response_payload = json.load(response)
255 + except error.HTTPError as exc: # pragma: no cover - exercised via message formatting
256 + detail = exc.read().decode("utf-8", errors="replace")
257 + raise RuntimeError(f"GitHub Models API request failed ({exc.code}): {detail}") from exc
258 + except error.URLError as exc: # pragma: no cover - network failures are environment-specific
259 + raise RuntimeError(f"GitHub Models API request failed: {exc.reason}") from exc
260 +
261 + return extract_markdown(response_payload)
262 +
263 +
264 +def main(argv: list[str] | None = None) -> int:
265 + args = parse_args(argv)
266 + output_path = args.output or default_output_path(args.current_datetime)
267 + prompt = render_prompt(
268 + prompt_template_path=args.prompt_template,
269 + current_datetime=args.current_datetime,
270 + output_path=output_path,
271 + analyzed_dir=args.analyzed_dir,
272 + snapshots_dir=args.snapshots_dir,
273 + wisdom_file=args.wisdom_file,
274 + skills_dir=args.skills_dir,
275 + limit=args.limit,
276 + )
277 +
278 + if args.print_prompt:
279 + print(prompt)
280 + return 0
281 +
282 + markdown = call_github_models(prompt)
283 + output_path.parent.mkdir(parents=True, exist_ok=True)
284 + output_path.write_text(markdown, encoding="utf-8")
285 + return 0
286 +
287 +
288 +if __name__ == "__main__":
289 + raise SystemExit(main())
scripts/track_quality.py new
+130
@@ -0,0 +1,130 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import argparse
5 +import sys
6 +from dataclasses import dataclass
7 +from pathlib import Path
8 +
9 +ROOT = Path(__file__).resolve().parent.parent
10 +if str(ROOT) not in sys.path:
11 + sys.path.insert(0, str(ROOT))
12 +
13 +from scripts import analysis_gate
14 +
15 +DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
16 +
17 +
18 +@dataclass(frozen=True)
19 +class QualityEntry:
20 + week: str
21 + score: int
22 + path: Path
23 +
24 +
25 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
26 + parser = argparse.ArgumentParser(description="Build a quality trend report from analyzed summaries.")
27 + parser.add_argument(
28 + "--analyzed-dir",
29 + type=Path,
30 + default=DEFAULT_ANALYZED_DIR,
31 + help="Directory containing analyzed weekly summaries.",
32 + )
33 + parser.add_argument(
34 + "--output",
35 + type=Path,
36 + help="Optional path to write the markdown report. Defaults to stdout.",
37 + )
38 + return parser.parse_args(argv)
39 +
40 +
41 +def load_quality_entries(analyzed_dir: Path) -> list[QualityEntry]:
42 + if not analyzed_dir.exists():
43 + return []
44 +
45 + entries: list[QualityEntry] = []
46 + for path in sorted(analyzed_dir.glob("*-summary.md")):
47 + frontmatter, _ = analysis_gate.extract_frontmatter(path.read_text(encoding="utf-8"))
48 + week = frontmatter.get("week")
49 + score = frontmatter.get("quality_score")
50 + if isinstance(week, str) and isinstance(score, int):
51 + entries.append(QualityEntry(week=week, score=score, path=path))
52 + return sorted(entries, key=lambda entry: entry.week)
53 +
54 +
55 +def classify_trend(entries: list[QualityEntry]) -> str:
56 + if len(entries) < 2:
57 + return "insufficient history"
58 +
59 + change = entries[-1].score - entries[0].score
60 + if change >= 5:
61 + return "improving"
62 + if change <= -5:
63 + return "declining"
64 + return "stable"
65 +
66 +
67 +def build_quality_report(analyzed_dir: Path) -> str:
68 + entries = load_quality_entries(analyzed_dir)
69 + lines = ["# Quality Trend Report", ""]
70 +
71 + if not entries:
72 + lines.extend(
73 + [
74 + "No analyzed summaries with a parseable `quality_score` were found.",
75 + "",
76 + "## Weekly Scores",
77 + "",
78 + "_No data available._",
79 + ]
80 + )
81 + return "\n".join(lines) + "\n"
82 +
83 + average_score = sum(entry.score for entry in entries) / len(entries)
84 + trend = classify_trend(entries)
85 + best = max(entries, key=lambda entry: entry.score)
86 + worst = min(entries, key=lambda entry: entry.score)
87 + latest = entries[-1]
88 +
89 + lines.extend(
90 + [
91 + f"- Summaries analyzed: {len(entries)}",
92 + f"- Average quality score: {average_score:.1f}",
93 + f"- Trend: {trend}",
94 + f"- Latest week: {latest.week} ({latest.score})",
95 + f"- Best week: {best.week} ({best.score})",
96 + f"- Lowest week: {worst.week} ({worst.score})",
97 + "",
98 + "## Weekly Scores",
99 + "",
100 + "| Week | Quality Score |",
101 + "| --- | ---: |",
102 + ]
103 + )
104 + for entry in entries:
105 + lines.append(f"| {entry.week} | {entry.score} |")
106 +
107 + lines.extend(
108 + [
109 + "",
110 + "## Interpretation",
111 + "",
112 + f"Quality is currently **{trend}** based on the available summaries. Use this trend as a calibration aid, not as a substitute for reviewing the underlying Signal/Noise/Gaps calls.",
113 + ]
114 + )
115 + return "\n".join(lines) + "\n"
116 +
117 +
118 +def main(argv: list[str] | None = None) -> int:
119 + args = parse_args(argv)
120 + report = build_quality_report(args.analyzed_dir)
121 + if args.output:
122 + args.output.parent.mkdir(parents=True, exist_ok=True)
123 + args.output.write_text(report, encoding="utf-8")
124 + else:
125 + print(report, end="")
126 + return 0
127 +
128 +
129 +if __name__ == "__main__":
130 + raise SystemExit(main())
tests/test_analyze_fallback.py
+35
@@ -66,6 +66,41 @@ class AnalyzeFallbackTests(unittest.TestCase):
66 self.assertIn('"week": "2026-W21"', prompt)
67 self.assertNotIn("{{CURRENT_DATETIME}}", prompt)
68
69 + def test_render_prompt_injects_wisdom_and_skills(self) -> None:
70 + tests_root = Path(__file__).resolve().parent
71 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
72 + base = Path(tmpdir)
73 + raw_path = base / "data" / "raw" / "2026-W21.json"
74 + analyzed_dir = base / "data" / "analyzed"
75 + prompt_template = base / "prompt.md"
76 + output_path = analyzed_dir / "2026-W21-summary.md"
77 + wisdom_path = base / ".squad" / "identity" / "wisdom.md"
78 + skills_dir = base / ".squad" / "skills" / "signal-detection"
79 + raw_path.parent.mkdir(parents=True)
80 + analyzed_dir.mkdir(parents=True)
81 + wisdom_path.parent.mkdir(parents=True)
82 + skills_dir.mkdir(parents=True)
83 +
84 + raw_path.write_text(json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}), encoding="utf-8")
85 + wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
86 + (skills_dir / "SKILL.md").write_text("# Skill\n\nReject wrapper churn.", encoding="utf-8")
87 + prompt_template.write_text("wisdom={{WISDOM}}\nskills={{SKILLS}}\n", encoding="utf-8")
88 +
89 + prompt = analyze_fallback.render_prompt(
90 + prompt_template_path=prompt_template,
91 + raw_json_path=raw_path,
92 + output_path=output_path,
93 + current_datetime="2026-05-18T13:05:53.678+02:00",
94 + analyzed_dir=analyzed_dir,
95 + wisdom_file=wisdom_path,
96 + skills_dir=base / ".squad" / "skills",
97 + )
98 +
99 + self.assertIn("Prefer durable signals.", prompt)
100 + self.assertIn("Reject wrapper churn.", prompt)
101 + self.assertNotIn("{{WISDOM}}", prompt)
102 + self.assertNotIn("{{SKILLS}}", prompt)
103 +
104 def test_extract_markdown_supports_message_parts(self) -> None:
105 payload = {
106 "choices": [
tests/test_pipeline.py
+47
@@ -7,6 +7,8 @@ from datetime import UTC, datetime
7 from pathlib import Path
8 from unittest import mock
9
10 +import yaml
11 +
12 import scripts.analysis_gate as analysis_gate
13 import scripts.analyze_fallback as analyze_fallback
14 import scripts.crawl as crawl
@@ -145,6 +147,51 @@ The week matters because practical automation won attention on merit. If this pa
147 '''
148
149
150 +class WorkflowConfigTests(unittest.TestCase):
151 + def test_crawl_workflow_persists_run_counter(self) -> None:
152 + workflow_path = Path(".github/workflows/crawl-and-publish.yml")
153 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
154 +
155 + crawl_job = workflow["jobs"]["crawl"]
156 + commit_step = None
157 + for step in crawl_job["steps"]:
158 + if step.get("name") == "Commit crawl data":
159 + commit_step = step
160 + break
161 +
162 + self.assertIsNotNone(commit_step, "Commit crawl data step not found")
163 + run_script = commit_step["run"]
164 + self.assertIn("COUNTER=$(cat .squad/run-counter.txt", run_script)
165 + self.assertIn("COUNTER=$((COUNTER + 1))", run_script)
166 + self.assertIn(".squad/run-counter.txt", run_script)
167 + self.assertIn("git add data/raw/ data/snapshots/ .squad/run-counter.txt", run_script)
168 +
169 + def test_crawl_workflow_defines_reskill_jobs(self) -> None:
170 + workflow_path = Path(".github/workflows/crawl-and-publish.yml")
171 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
172 +
173 + self.assertIn("reskill-check", workflow["jobs"])
174 + reskill_check = workflow["jobs"]["reskill-check"]
175 + self.assertEqual(reskill_check["needs"], ["crawl"])
176 +
177 + self.assertIn("reskill", workflow["jobs"])
178 + reskill = workflow["jobs"]["reskill"]
179 + self.assertEqual(reskill["needs"], ["reskill-check"])
180 + self.assertIn("needs.reskill-check.outputs.should_reskill", reskill["if"])
181 +
182 + check_step = next((s for s in reskill_check["steps"] if s.get("name") == "Check reskill trigger"), None)
183 + self.assertIsNotNone(check_step)
184 + check_run = check_step["run"]
185 + self.assertIn("reskill=true", check_run)
186 + self.assertIn("$GITHUB_OUTPUT", check_run)
187 +
188 + reskill_step = next((s for s in reskill["steps"] if s.get("name") == "Run placeholder reskill"), None)
189 + self.assertIsNotNone(reskill_step)
190 + reskill_run = reskill_step["run"]
191 + self.assertIn("mkdir -p .squad/skills .squad/reskill", reskill_run)
192 + self.assertIn("trigger-log.txt", reskill_run)
193 +
194 +
195 class PipelineIntegrationTests(unittest.TestCase):
196 def test_crawl_script_produces_valid_json_output_schema(self) -> None:
197 tests_root = Path(__file__).resolve().parent
tests/test_reskill.py new
+120
@@ -0,0 +1,120 @@
1 +import io
2 +import json
3 +import tempfile
4 +import unittest
5 +from pathlib import Path
6 +from unittest import mock
7 +
8 +import scripts.reskill as reskill
9 +
10 +
11 +class _FakeHTTPResponse(io.BytesIO):
12 + def __enter__(self):
13 + return self
14 +
15 + def __exit__(self, exc_type, exc, tb):
16 + self.close()
17 + return False
18 +
19 +
20 +class ReskillTests(unittest.TestCase):
21 + def test_render_prompt_includes_recent_context(self) -> None:
22 + tests_root = Path(__file__).resolve().parent
23 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
24 + base = Path(tmpdir)
25 + analyzed_dir = base / "data" / "analyzed"
26 + snapshots_dir = base / "data" / "snapshots"
27 + wisdom_path = base / ".squad" / "identity" / "wisdom.md"
28 + skills_dir = base / ".squad" / "skills" / "trend-detection"
29 + prompt_template = base / "reskill.md"
30 + output_path = base / ".squad" / "reskill" / "2026-W21.md"
31 + analyzed_dir.mkdir(parents=True)
32 + snapshots_dir.mkdir(parents=True)
33 + wisdom_path.parent.mkdir(parents=True)
34 + skills_dir.mkdir(parents=True)
35 + output_path.parent.mkdir(parents=True)
36 +
37 + for week, score in [("2026-W17", 61), ("2026-W18", 66), ("2026-W19", 70), ("2026-W20", 74), ("2026-W21", 79), ("2026-W22", 84)]:
38 + (analyzed_dir / f"{week}-summary.md").write_text(
39 + f"---\nweek: {week}\nquality_score: {score}\n---\n\n## Trend Analysis\n\n### Signal\n\nSignal {week}.\n",
40 + encoding="utf-8",
41 + )
42 + (snapshots_dir / "2026-W21-stars.json").write_text(json.dumps({"octo/signal-kit": 120}), encoding="utf-8")
43 + wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
44 + (skills_dir / "SKILL.md").write_text("# Skill\n\nWatch for wrapper churn.", encoding="utf-8")
45 + prompt_template.write_text(
46 + "out={{OUTPUT_PATH}}\nwisdom={{WISDOM}}\nskills={{SKILLS}}\nquality={{QUALITY_TREND}}\nanalyses={{RECENT_ANALYSES}}\nsnapshots={{SNAPSHOT_CONTEXT}}\n",
47 + encoding="utf-8",
48 + )
49 +
50 + prompt = reskill.render_prompt(
51 + prompt_template_path=prompt_template,
52 + current_datetime="2026-05-18T15:22:25.067+02:00",
53 + output_path=output_path,
54 + analyzed_dir=analyzed_dir,
55 + snapshots_dir=snapshots_dir,
56 + wisdom_file=wisdom_path,
57 + skills_dir=base / ".squad" / "skills",
58 + limit=5,
59 + )
60 +
61 + self.assertIn(f"out={output_path}", prompt)
62 + self.assertIn("Prefer durable signals.", prompt)
63 + self.assertIn("Watch for wrapper churn.", prompt)
64 + self.assertIn("Average quality score", prompt)
65 + self.assertNotIn("2026-W17-summary.md", prompt)
66 + self.assertIn("2026-W18-summary.md", prompt)
67 + self.assertIn("2026-W21-stars.json", prompt)
68 + self.assertIn("No snapshot data available for hindsight validation.", prompt)
69 +
70 + def test_main_writes_default_weekly_report(self) -> None:
71 + tests_root = Path(__file__).resolve().parent
72 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
73 + base = Path(tmpdir)
74 + analyzed_dir = base / "data" / "analyzed"
75 + snapshots_dir = base / "data" / "snapshots"
76 + wisdom_path = base / ".squad" / "identity" / "wisdom.md"
77 + skills_dir = base / ".squad" / "skills"
78 + prompt_template = base / "reskill.md"
79 + analyzed_dir.mkdir(parents=True)
80 + snapshots_dir.mkdir(parents=True)
81 + wisdom_path.parent.mkdir(parents=True)
82 + skills_dir.mkdir(parents=True)
83 + (analyzed_dir / "2026-W21-summary.md").write_text(
84 + "---\nweek: 2026-W21\nquality_score: 76\n---\n\nBody\n",
85 + encoding="utf-8",
86 + )
87 + wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
88 + prompt_template.write_text("{{WISDOM}}\n{{QUALITY_TREND}}", encoding="utf-8")
89 +
90 + response = _FakeHTTPResponse(
91 + json.dumps({"choices": [{"message": {"content": "# Reskill Report\n"}}]}).encode("utf-8")
92 + )
93 +
94 + with mock.patch.object(reskill, "DEFAULT_REPORT_DIR", base / ".squad" / "reskill"), mock.patch.dict(
95 + "os.environ", {"GITHUB_TOKEN": "token"}, clear=False
96 + ), mock.patch.object(reskill.request, "urlopen", return_value=response):
97 + exit_code = reskill.main(
98 + [
99 + "--current-datetime",
100 + "2026-05-18T15:22:25.067+02:00",
101 + "--prompt-template",
102 + str(prompt_template),
103 + "--analyzed-dir",
104 + str(analyzed_dir),
105 + "--snapshots-dir",
106 + str(snapshots_dir),
107 + "--wisdom-file",
108 + str(wisdom_path),
109 + "--skills-dir",
110 + str(skills_dir),
111 + ]
112 + )
113 +
114 + self.assertEqual(exit_code, 0)
115 + output_path = base / ".squad" / "reskill" / "2026-W21.md"
116 + self.assertEqual(output_path.read_text(encoding="utf-8"), "# Reskill Report\n")
117 +
118 +
119 +if __name__ == "__main__":
120 + unittest.main()
tests/test_track_quality.py new
+47
@@ -0,0 +1,47 @@
1 +import tempfile
2 +import unittest
3 +from pathlib import Path
4 +
5 +import scripts.track_quality as track_quality
6 +
7 +
8 +class TrackQualityTests(unittest.TestCase):
9 + def test_build_quality_report_summarizes_scores(self) -> None:
10 + tests_root = Path(__file__).resolve().parent
11 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
12 + analyzed_dir = Path(tmpdir) / "analyzed"
13 + analyzed_dir.mkdir()
14 + (analyzed_dir / "2026-W19-summary.md").write_text(
15 + "---\nweek: 2026-W19\nquality_score: 65\n---\n\nBody\n",
16 + encoding="utf-8",
17 + )
18 + (analyzed_dir / "2026-W20-summary.md").write_text(
19 + "---\nweek: 2026-W20\nquality_score: 72\n---\n\nBody\n",
20 + encoding="utf-8",
21 + )
22 + (analyzed_dir / "2026-W21-summary.md").write_text(
23 + "---\nweek: 2026-W21\nquality_score: 81\n---\n\nBody\n",
24 + encoding="utf-8",
25 + )
26 +
27 + report = track_quality.build_quality_report(analyzed_dir)
28 +
29 + self.assertIn("Summaries analyzed: 3", report)
30 + self.assertIn("Average quality score: 72.7", report)
31 + self.assertIn("Trend: improving", report)
32 + self.assertIn("| 2026-W21 | 81 |", report)
33 +
34 + def test_build_quality_report_handles_missing_entries(self) -> None:
35 + tests_root = Path(__file__).resolve().parent
36 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
37 + analyzed_dir = Path(tmpdir) / "analyzed"
38 + analyzed_dir.mkdir()
39 +
40 + report = track_quality.build_quality_report(analyzed_dir)
41 +
42 + self.assertIn("No analyzed summaries", report)
43 + self.assertIn("_No data available._", report)
44 +
45 +
46 +if __name__ == "__main__":
47 + unittest.main()