agentforge-framework 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Calculate readability metrics for transformed text.
|
|
4
|
+
|
|
5
|
+
Outputs:
|
|
6
|
+
- Flesch-Kincaid grade level
|
|
7
|
+
- Sentence length variance
|
|
8
|
+
- Word repetition score
|
|
9
|
+
- Paragraph length stats
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python readability_metrics.py < input.txt
|
|
13
|
+
python readability_metrics.py input.txt
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
import re
|
|
21
|
+
import json
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from typing import TypedDict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReadabilityMetrics(TypedDict):
|
|
27
|
+
flesch_kincaid_grade: float
|
|
28
|
+
flesch_reading_ease: float
|
|
29
|
+
sentence_count: int
|
|
30
|
+
word_count: int
|
|
31
|
+
avg_sentence_length: float
|
|
32
|
+
sentence_length_variance: float
|
|
33
|
+
min_sentence_length: int
|
|
34
|
+
max_sentence_length: int
|
|
35
|
+
consecutive_similar_length: int
|
|
36
|
+
word_repetition_score: float
|
|
37
|
+
top_repeated_words: list[tuple[str, int]]
|
|
38
|
+
paragraph_count: int
|
|
39
|
+
avg_paragraph_length: float
|
|
40
|
+
flags: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def count_syllables(word: str) -> int:
|
|
44
|
+
"""Count syllables in a word (approximation)."""
|
|
45
|
+
word = word.lower().strip()
|
|
46
|
+
if not word:
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
# Handle special cases
|
|
50
|
+
if len(word) <= 3:
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
# Count vowel groups
|
|
54
|
+
vowels = "aeiouy"
|
|
55
|
+
count = 0
|
|
56
|
+
prev_is_vowel = False
|
|
57
|
+
|
|
58
|
+
for char in word:
|
|
59
|
+
is_vowel = char in vowels
|
|
60
|
+
if is_vowel and not prev_is_vowel:
|
|
61
|
+
count += 1
|
|
62
|
+
prev_is_vowel = is_vowel
|
|
63
|
+
|
|
64
|
+
# Adjust for silent e
|
|
65
|
+
if word.endswith('e') and count > 1:
|
|
66
|
+
count -= 1
|
|
67
|
+
|
|
68
|
+
# Adjust for -le endings
|
|
69
|
+
if word.endswith('le') and len(word) > 2 and word[-3] not in vowels:
|
|
70
|
+
count += 1
|
|
71
|
+
|
|
72
|
+
return max(1, count)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def split_sentences(text: str) -> list[str]:
|
|
76
|
+
"""Split text into sentences."""
|
|
77
|
+
parts = re.split(r'([.!?]["”]?)\s+(?=["“]?[A-Z])', text)
|
|
78
|
+
sentences = []
|
|
79
|
+
current = ""
|
|
80
|
+
for part in parts:
|
|
81
|
+
if re.match(r'[.!?]["”]?$', part):
|
|
82
|
+
current += part
|
|
83
|
+
sentences.append(current.strip())
|
|
84
|
+
current = ""
|
|
85
|
+
else:
|
|
86
|
+
current += part
|
|
87
|
+
if current.strip():
|
|
88
|
+
sentences.append(current.strip())
|
|
89
|
+
# Filter empty and clean up
|
|
90
|
+
return [s.strip() for s in sentences if s.strip()]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def split_staccato_units(text: str) -> list[str]:
|
|
94
|
+
units = re.split(r'\s*[—;]\s*|(?<=[.!?])\s+', text)
|
|
95
|
+
return [u.strip() for u in units if u.strip()]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def split_words(text: str) -> list[str]:
|
|
99
|
+
"""Split text into words."""
|
|
100
|
+
# Remove punctuation except hyphens in words. Numeric tokens are real words
|
|
101
|
+
# (a data table is not "empty text"), so keep them.
|
|
102
|
+
text = re.sub(r'[^\w\s-]', ' ', text)
|
|
103
|
+
words = text.lower().split()
|
|
104
|
+
return [w for w in words if w]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calculate_metrics(text: str) -> ReadabilityMetrics:
|
|
108
|
+
"""Calculate all readability metrics."""
|
|
109
|
+
sentences = split_sentences(text)
|
|
110
|
+
words = split_words(text)
|
|
111
|
+
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
|
|
112
|
+
|
|
113
|
+
if not sentences or not words:
|
|
114
|
+
return {
|
|
115
|
+
"flesch_kincaid_grade": 0,
|
|
116
|
+
"flesch_reading_ease": 0,
|
|
117
|
+
"sentence_count": 0,
|
|
118
|
+
"word_count": 0,
|
|
119
|
+
"avg_sentence_length": 0,
|
|
120
|
+
"sentence_length_variance": 0,
|
|
121
|
+
"min_sentence_length": 0,
|
|
122
|
+
"max_sentence_length": 0,
|
|
123
|
+
"consecutive_similar_length": 0,
|
|
124
|
+
"word_repetition_score": 0,
|
|
125
|
+
"top_repeated_words": [],
|
|
126
|
+
"paragraph_count": len(paragraphs),
|
|
127
|
+
"avg_paragraph_length": 0,
|
|
128
|
+
"flags": ["Empty or invalid text"]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Basic counts
|
|
132
|
+
sentence_count = len(sentences)
|
|
133
|
+
word_count = len(words)
|
|
134
|
+
syllable_count = sum(count_syllables(w) for w in words)
|
|
135
|
+
|
|
136
|
+
# Sentence lengths
|
|
137
|
+
sentence_lengths = [len(split_words(s)) for s in sentences]
|
|
138
|
+
avg_sentence_length = word_count / sentence_count if sentence_count else 0
|
|
139
|
+
|
|
140
|
+
# Variance calculation
|
|
141
|
+
if len(sentence_lengths) > 1:
|
|
142
|
+
mean = sum(sentence_lengths) / len(sentence_lengths)
|
|
143
|
+
variance = sum((x - mean) ** 2 for x in sentence_lengths) / len(sentence_lengths)
|
|
144
|
+
else:
|
|
145
|
+
variance = 0
|
|
146
|
+
|
|
147
|
+
# Consecutive similar length sentences
|
|
148
|
+
consecutive_similar = 0
|
|
149
|
+
max_consecutive = 0
|
|
150
|
+
for i in range(1, len(sentence_lengths)):
|
|
151
|
+
# "Similar" = within 3 words of each other
|
|
152
|
+
if abs(sentence_lengths[i] - sentence_lengths[i-1]) <= 3:
|
|
153
|
+
consecutive_similar += 1
|
|
154
|
+
max_consecutive = max(max_consecutive, consecutive_similar)
|
|
155
|
+
else:
|
|
156
|
+
consecutive_similar = 0
|
|
157
|
+
|
|
158
|
+
# Longest run of tiny (<=5-word) sentence-like units — the staccato "anti-slop" cadence.
|
|
159
|
+
# Tracked independently of sentence_count so short de-slopped outputs don't slip.
|
|
160
|
+
staccato_lengths = [len(split_words(s)) for s in split_staccato_units(text)]
|
|
161
|
+
staccato_run = 0
|
|
162
|
+
max_staccato_run = 0
|
|
163
|
+
for length in staccato_lengths:
|
|
164
|
+
if length <= 5:
|
|
165
|
+
staccato_run += 1
|
|
166
|
+
max_staccato_run = max(max_staccato_run, staccato_run)
|
|
167
|
+
else:
|
|
168
|
+
staccato_run = 0
|
|
169
|
+
|
|
170
|
+
# Flesch-Kincaid calculations
|
|
171
|
+
avg_syllables_per_word = syllable_count / word_count if word_count else 0
|
|
172
|
+
|
|
173
|
+
flesch_reading_ease = (
|
|
174
|
+
206.835
|
|
175
|
+
- (1.015 * avg_sentence_length)
|
|
176
|
+
- (84.6 * avg_syllables_per_word)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
flesch_kincaid_grade = (
|
|
180
|
+
(0.39 * avg_sentence_length)
|
|
181
|
+
+ (11.8 * avg_syllables_per_word)
|
|
182
|
+
- 15.59
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Word repetition (excluding common words)
|
|
186
|
+
stop_words = {
|
|
187
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
188
|
+
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
189
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
190
|
+
'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need',
|
|
191
|
+
'it', 'its', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she',
|
|
192
|
+
'we', 'they', 'what', 'which', 'who', 'when', 'where', 'why', 'how',
|
|
193
|
+
'not', 'no', 'yes', 'if', 'then', 'else', 'so', 'as', 'than', 'just'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
content_words = [w for w in words if w not in stop_words and len(w) > 2]
|
|
197
|
+
word_freq = Counter(content_words)
|
|
198
|
+
|
|
199
|
+
# Repetition score: percentage of content words appearing 3+ times
|
|
200
|
+
repeated_words = {w: c for w, c in word_freq.items() if c >= 3}
|
|
201
|
+
repetition_score = (
|
|
202
|
+
sum(repeated_words.values()) / len(content_words) * 100
|
|
203
|
+
if content_words else 0
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
top_repeated = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
207
|
+
|
|
208
|
+
# Paragraph stats
|
|
209
|
+
para_lengths = [len(split_words(p)) for p in paragraphs]
|
|
210
|
+
avg_para_length = sum(para_lengths) / len(para_lengths) if para_lengths else 0
|
|
211
|
+
|
|
212
|
+
# Generate flags
|
|
213
|
+
flags: list[str] = []
|
|
214
|
+
|
|
215
|
+
if flesch_kincaid_grade > 12:
|
|
216
|
+
flags.append(f"High reading level ({flesch_kincaid_grade:.1f} grade)")
|
|
217
|
+
|
|
218
|
+
if variance < 10 and sentence_count > 3:
|
|
219
|
+
flags.append("Low sentence length variance (monotonous rhythm)")
|
|
220
|
+
|
|
221
|
+
if max_consecutive >= 3:
|
|
222
|
+
flags.append(f"{max_consecutive}+ consecutive similar-length sentences")
|
|
223
|
+
|
|
224
|
+
if repetition_score > 15:
|
|
225
|
+
flags.append(f"High word repetition ({repetition_score:.1f}%)")
|
|
226
|
+
|
|
227
|
+
if avg_sentence_length > 25:
|
|
228
|
+
flags.append(f"Long average sentence length ({avg_sentence_length:.1f} words)")
|
|
229
|
+
|
|
230
|
+
if avg_sentence_length < 8 and sentence_count > 3:
|
|
231
|
+
flags.append("Very short sentences (may feel choppy)")
|
|
232
|
+
|
|
233
|
+
# Staccato cadence is the loudest "anti-slop AI" signature; flag it even on
|
|
234
|
+
# short outputs (no sentence_count gate) so de-slopped text can't hide it.
|
|
235
|
+
if max_staccato_run >= 3:
|
|
236
|
+
flags.append(f"Staccato cadence: {max_staccato_run} consecutive tiny sentences (anti-slop AI tell)")
|
|
237
|
+
elif avg_sentence_length < 6 and sentence_count >= 2:
|
|
238
|
+
flags.append(f"Staccato cadence (avg {avg_sentence_length:.1f} words/sentence — anti-slop AI tell)")
|
|
239
|
+
|
|
240
|
+
if max(sentence_lengths) - min(sentence_lengths) < 5 and sentence_count > 5:
|
|
241
|
+
flags.append("Sentences all similar length (AI tell)")
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"flesch_kincaid_grade": round(flesch_kincaid_grade, 1),
|
|
245
|
+
"flesch_reading_ease": round(flesch_reading_ease, 1),
|
|
246
|
+
"sentence_count": sentence_count,
|
|
247
|
+
"word_count": word_count,
|
|
248
|
+
"avg_sentence_length": round(avg_sentence_length, 1),
|
|
249
|
+
"sentence_length_variance": round(variance, 1),
|
|
250
|
+
"min_sentence_length": min(sentence_lengths) if sentence_lengths else 0,
|
|
251
|
+
"max_sentence_length": max(sentence_lengths) if sentence_lengths else 0,
|
|
252
|
+
"consecutive_similar_length": max_consecutive,
|
|
253
|
+
"word_repetition_score": round(repetition_score, 1),
|
|
254
|
+
"top_repeated_words": top_repeated,
|
|
255
|
+
"paragraph_count": len(paragraphs),
|
|
256
|
+
"avg_paragraph_length": round(avg_para_length, 1),
|
|
257
|
+
"flags": flags
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
262
|
+
parser = argparse.ArgumentParser(
|
|
263
|
+
description="Calculate readability metrics for transformed text."
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument("path", nargs="?", help="Path to input text file (default: read stdin)")
|
|
266
|
+
return parser.parse_args(argv)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main() -> None:
|
|
270
|
+
args = parse_args(sys.argv[1:])
|
|
271
|
+
|
|
272
|
+
# Read input
|
|
273
|
+
if args.path:
|
|
274
|
+
try:
|
|
275
|
+
with open(args.path, 'r', errors="replace") as f:
|
|
276
|
+
text = f.read()
|
|
277
|
+
except OSError as e:
|
|
278
|
+
print(json.dumps({"error": f"Could not read input: {e}"}))
|
|
279
|
+
sys.exit(2)
|
|
280
|
+
else:
|
|
281
|
+
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
282
|
+
|
|
283
|
+
if not text.strip():
|
|
284
|
+
print(json.dumps({"error": "No input provided"}))
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
metrics = calculate_metrics(text)
|
|
288
|
+
print(json.dumps(metrics, indent=2))
|
|
289
|
+
|
|
290
|
+
# Exit with 1 if any flags (warnings)
|
|
291
|
+
sys.exit(1 if metrics["flags"] else 0)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Report staleness of the three inputs the adversarial-refresh promise depends
|
|
4
|
+
on: the Wikipedia wiki-sync state, the model-parity bench, and the newest
|
|
5
|
+
adversarial-eval row. Pure stdlib, network-free, always exits 0 — this is a
|
|
6
|
+
reporter an agent reads before deciding what to run next
|
|
7
|
+
(see references/refresh.md), not a gate that blocks anything itself.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/refresh_status.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import date, datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
WIKI_STATE_FILE = ROOT / "scripts" / ".wiki_sync_state.json"
|
|
24
|
+
PIPELINE_DOC = ROOT / "references" / "pipeline.md"
|
|
25
|
+
ADVERSARIAL_EVALS = ROOT / "evals" / "adversarial-evals.json"
|
|
26
|
+
|
|
27
|
+
# Guidance thresholds, not hard limits -- the numbers below are the point at
|
|
28
|
+
# which an agent should proactively check, not a required cadence. Every
|
|
29
|
+
# field is a plain reporter value; nothing here fails the build.
|
|
30
|
+
#
|
|
31
|
+
# Wikipedia's "Signs of AI writing" page is a slow-moving reference page;
|
|
32
|
+
# quarterly is enough to catch drift without chasing noise.
|
|
33
|
+
WIKI_STALE_DAYS = 90
|
|
34
|
+
# The GPT/Anthropic model spectrums shift on the order of months, and the
|
|
35
|
+
# rule in references/pipeline.md already forces a re-run on any co-writer,
|
|
36
|
+
# mimic, or detector-pack change regardless of this threshold -- this is a
|
|
37
|
+
# backstop for the case where no such change happened but time still passed.
|
|
38
|
+
BENCH_STALE_DAYS = 180
|
|
39
|
+
# The adversarial-eval catalog is the product's growth signal (docs/PRODUCT.md,
|
|
40
|
+
# Growth); a two-month gap with no new row is worth a look even if nothing
|
|
41
|
+
# else prompted one.
|
|
42
|
+
ROW_STALE_DAYS = 60
|
|
43
|
+
|
|
44
|
+
# The convention this script relies on in references/pipeline.md: the
|
|
45
|
+
# recorded parity date is written as "Live matrix recorded **YYYY-MM-DD**."
|
|
46
|
+
# If that sentence's wording changes, this regex needs to change with it.
|
|
47
|
+
PIPELINE_DATE_RE = re.compile(
|
|
48
|
+
r"Live matrix recorded \*\*(\d{4}-\d{2}-\d{2})\*\*"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _today() -> date:
|
|
53
|
+
return datetime.now(timezone.utc).date()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _days_since(d: date | None) -> int | None:
|
|
57
|
+
if d is None:
|
|
58
|
+
return None
|
|
59
|
+
return (_today() - d).days
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _status(last: date | None, stale_days: int) -> dict:
|
|
63
|
+
days = _days_since(last)
|
|
64
|
+
stale = days is None or days > stale_days
|
|
65
|
+
return {
|
|
66
|
+
"last": last.isoformat() if last else None,
|
|
67
|
+
"days": days,
|
|
68
|
+
"stale": stale,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_iso_date(value: str) -> date | None:
|
|
73
|
+
"""Best-effort parse of an ISO-8601 timestamp (MediaWiki style, 'Z'
|
|
74
|
+
suffix included) down to a plain date."""
|
|
75
|
+
try:
|
|
76
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
|
|
77
|
+
except (ValueError, AttributeError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def wiki_sync_status() -> dict:
|
|
82
|
+
if not WIKI_STATE_FILE.exists():
|
|
83
|
+
# Never synced. Treat as stale so it surfaces for attention.
|
|
84
|
+
return _status(None, WIKI_STALE_DAYS)
|
|
85
|
+
|
|
86
|
+
last: date | None = None
|
|
87
|
+
try:
|
|
88
|
+
state = json.loads(WIKI_STATE_FILE.read_text())
|
|
89
|
+
last = _parse_iso_date(state.get("last_timestamp", ""))
|
|
90
|
+
except (json.JSONDecodeError, OSError):
|
|
91
|
+
last = None
|
|
92
|
+
|
|
93
|
+
if last is None:
|
|
94
|
+
# Content didn't parse; fall back to the state file's mtime.
|
|
95
|
+
try:
|
|
96
|
+
mtime = WIKI_STATE_FILE.stat().st_mtime
|
|
97
|
+
last = datetime.fromtimestamp(mtime, tz=timezone.utc).date()
|
|
98
|
+
except OSError:
|
|
99
|
+
last = None
|
|
100
|
+
|
|
101
|
+
return _status(last, WIKI_STALE_DAYS)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parity_bench_status() -> dict:
|
|
105
|
+
last: date | None = None
|
|
106
|
+
try:
|
|
107
|
+
text = PIPELINE_DOC.read_text()
|
|
108
|
+
match = PIPELINE_DATE_RE.search(text)
|
|
109
|
+
if match:
|
|
110
|
+
last = date.fromisoformat(match.group(1))
|
|
111
|
+
except OSError:
|
|
112
|
+
last = None
|
|
113
|
+
|
|
114
|
+
return _status(last, BENCH_STALE_DAYS)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def newest_pattern_row_status() -> dict:
|
|
118
|
+
last: date | None = None
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
[
|
|
122
|
+
"git",
|
|
123
|
+
"log",
|
|
124
|
+
"-1",
|
|
125
|
+
"--format=%cs",
|
|
126
|
+
"--",
|
|
127
|
+
str(ADVERSARIAL_EVALS),
|
|
128
|
+
],
|
|
129
|
+
cwd=str(ROOT),
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=10,
|
|
133
|
+
)
|
|
134
|
+
out = result.stdout.strip()
|
|
135
|
+
if result.returncode == 0 and out:
|
|
136
|
+
last = date.fromisoformat(out)
|
|
137
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
138
|
+
last = None
|
|
139
|
+
|
|
140
|
+
return _status(last, ROW_STALE_DAYS)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> None:
|
|
144
|
+
output = {
|
|
145
|
+
"wiki_sync": wiki_sync_status(),
|
|
146
|
+
"parity_bench": parity_bench_status(),
|
|
147
|
+
"newest_pattern_row": newest_pattern_row_status(),
|
|
148
|
+
}
|
|
149
|
+
print(json.dumps(output, indent=2))
|
|
150
|
+
sys.exit(0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|