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.
Files changed (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env python3
2
+ """Harvest user-authored writing samples from transcripts and declared folders.
3
+
4
+ Adapters:
5
+ - claude-jsonl: JSONL transcript files with explicit user/assistant roles. Unknown
6
+ schemas are skipped with a warning; authorship is never guessed.
7
+ - codex-jsonl: Codex CLI/Desktop session JSONL (~/.codex/sessions/YYYY/MM/DD/
8
+ rollout-*.jsonl). Reads `event_msg` user_message text and user-role
9
+ `response_item` message content; drops assistant/developer/tool rows and
10
+ filters instruction-injection wrappers (AGENTS.md dumps, <environment_context>,
11
+ and similar structural markers) that Codex injects into user-role turns.
12
+ - text-folder: directories containing .md/.txt files. These are user-authored by
13
+ declaration, but still pass through the AI-contamination tripwire.
14
+
15
+ Adapter detection for .jsonl files is by content shape, not filename: each file
16
+ is peeked line-by-line until a recognizable claude-jsonl or codex-jsonl entry is
17
+ found. Files that never match either shape fall through to the claude-jsonl
18
+ parser's own "unknown schema" warning path.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import re
26
+ import sys
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ ROOT = Path(__file__).resolve().parent.parent
32
+ sys.path.insert(0, str(ROOT / "scripts"))
33
+
34
+ import banned_phrase_scan # noqa: E402
35
+ import structure_scan # noqa: E402
36
+
37
+
38
+ WORD_RE = re.compile(r"[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?")
39
+ SENTENCE_RE = re.compile(r"[.!?](?:\s|$)")
40
+ COMMAND_RE = re.compile(
41
+ r"^\s*(?:/[\w-]+|(?:open|read|write|edit|fix|review|run|grep|search|cat|"
42
+ r"sed|python3?|npm|git)\b.*(?:/[\w./-]+|--?\w+))",
43
+ re.I,
44
+ )
45
+ TAG_RE = re.compile(r"<(?:system-reminder|[^>\s]+)[^>]*>[\s\S]*?</(?:system-reminder|[^>\s]+)>", re.I)
46
+ QUOTE_ASSISTANT_RE = re.compile(r"(?im)^\s*(?:>|you said:|assistant:|claude said:)")
47
+ FILLER_RE = re.compile(r"\b(?:um|uh)\b,?", re.I)
48
+ DATE_FLOOR = 0.0
49
+
50
+
51
+ def words(text: str) -> list[str]:
52
+ return WORD_RE.findall(text)
53
+
54
+
55
+ def normalize_text(text: str) -> str:
56
+ return re.sub(r"\s+", " ", text).strip()
57
+
58
+
59
+ def normal_tokens(text: str) -> list[str]:
60
+ return [w.lower() for w in words(text)]
61
+
62
+
63
+ def fivegrams(text: str) -> set[tuple[str, ...]]:
64
+ toks = normal_tokens(text)
65
+ return set(tuple(toks[i:i + 5]) for i in range(max(0, len(toks) - 4)))
66
+
67
+
68
+ def complete_sentence_count(text: str) -> int:
69
+ return len(SENTENCE_RE.findall(text))
70
+
71
+
72
+ def extract_text(content: Any) -> str:
73
+ if isinstance(content, str):
74
+ return content
75
+ if isinstance(content, list):
76
+ parts = []
77
+ for item in content:
78
+ if isinstance(item, str):
79
+ parts.append(item)
80
+ elif isinstance(item, dict) and isinstance(item.get("text"), str):
81
+ parts.append(item["text"])
82
+ return "\n".join(parts)
83
+ return ""
84
+
85
+
86
+ def role_from_entry(entry: dict[str, Any]) -> str | None:
87
+ msg = entry.get("message")
88
+ role = msg.get("role") if isinstance(msg, dict) else None
89
+ if role in {"user", "assistant"}:
90
+ return role
91
+ top = entry.get("type")
92
+ if top in {"user", "assistant"}:
93
+ return top
94
+ return None
95
+
96
+
97
+ def message_text(entry: dict[str, Any]) -> str:
98
+ msg = entry.get("message")
99
+ if isinstance(msg, dict):
100
+ return extract_text(msg.get("content"))
101
+ return extract_text(entry.get("content"))
102
+
103
+
104
+ # Codex CLI/Desktop session envelopes: {"timestamp", "type", "payload"}. These are
105
+ # the top-level `type` values actually observed (and defensively allowed) in
106
+ # ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. `user_message`/`agent_message` are
107
+ # not top-level types themselves -- they are `payload.type` values nested inside
108
+ # an `event_msg` envelope.
109
+ CODEX_TOP_TYPES = {"session_meta", "event_msg", "response_item", "turn_context", "compacted"}
110
+
111
+ # Structural markers Codex injects into user-role turns that are not the user's
112
+ # own writing: a repo's AGENTS.md dumped verbatim, the environment/skill/turn
113
+ # banners, or an explicit user-instructions wrapper. Filtering is by these
114
+ # prefixes (structure), never by guessing at content.
115
+ CODEX_INJECTION_PREFIXES = (
116
+ "# AGENTS.md instructions for",
117
+ "<environment_context>",
118
+ "<user_instructions>",
119
+ "<INSTRUCTIONS>",
120
+ "<skill>",
121
+ "<turn_aborted>",
122
+ )
123
+
124
+
125
+ def is_codex_envelope(entry: dict[str, Any]) -> bool:
126
+ return entry.get("type") in CODEX_TOP_TYPES and isinstance(entry.get("payload"), dict)
127
+
128
+
129
+ def is_injection_wrapper(text: str) -> bool:
130
+ return text.strip().startswith(CODEX_INJECTION_PREFIXES)
131
+
132
+
133
+ def detect_jsonl_adapter(path: Path) -> str:
134
+ """Peek at a JSONL file's shape to pick claude-jsonl or codex-jsonl parsing.
135
+
136
+ Falls back to claude-jsonl (which itself warns and skips truly unknown
137
+ schemas) when neither shape is recognized in any line.
138
+ """
139
+ for line in path.read_text(errors="replace").splitlines():
140
+ if not line.strip():
141
+ continue
142
+ try:
143
+ entry = json.loads(line)
144
+ except json.JSONDecodeError:
145
+ continue
146
+ if not isinstance(entry, dict):
147
+ continue
148
+ if is_codex_envelope(entry):
149
+ return "codex-jsonl"
150
+ if role_from_entry(entry) is not None:
151
+ return "claude-jsonl"
152
+ return "claude-jsonl"
153
+
154
+
155
+ def strip_transcript_noise(text: str) -> str:
156
+ text = TAG_RE.sub(" ", text)
157
+ lines = []
158
+ for line in text.splitlines():
159
+ if re.match(r"^\s*>", line):
160
+ continue
161
+ lines.append(line)
162
+ return normalize_text("\n".join(lines))
163
+
164
+
165
+ def is_quoted_assistant(text: str) -> bool:
166
+ if QUOTE_ASSISTANT_RE.search(text):
167
+ return True
168
+ lowered = text.lower().strip()
169
+ return lowered.startswith(("you wrote:", "your answer:", "your response:"))
170
+
171
+
172
+ def is_command_like(text: str) -> bool:
173
+ stripped = text.strip()
174
+ if COMMAND_RE.search(stripped):
175
+ return True
176
+ if len(words(stripped)) <= 8 and re.search(r"(^|\s)(?:/[\w./-]+|--?\w+)", stripped):
177
+ return True
178
+ return False
179
+
180
+
181
+ def dictated(text: str) -> bool:
182
+ w = max(1, len(words(text)))
183
+ fillers = FILLER_RE.findall(text)
184
+ return len(fillers) >= 2 and len(fillers) / w > 0.035
185
+
186
+
187
+ def tripwire(text: str) -> bool:
188
+ banned = banned_phrase_scan.scan_for_violations(text, include_quoted=True)
189
+ structure = structure_scan.scan(text)
190
+ categories = {v["category"] for v in banned}
191
+ categories.update(f"struct:{f['metric']}" for f in structure["flags"])
192
+ hard = any(v["severity"] == "hard" for v in banned)
193
+ return hard or len(categories) >= 2
194
+
195
+
196
+ def source_date(entry: dict[str, Any]) -> str | None:
197
+ raw = entry.get("timestamp") or entry.get("created_at")
198
+ if not isinstance(raw, str):
199
+ return None
200
+ return raw
201
+
202
+
203
+ def parse_since(value: str | None) -> datetime | None:
204
+ if not value:
205
+ return None
206
+ return datetime.fromisoformat(value)
207
+
208
+
209
+ def date_ok(date: str | None, since: datetime | None) -> bool:
210
+ if not since or not date:
211
+ return True
212
+ try:
213
+ return datetime.fromisoformat(date.replace("Z", "+00:00")).replace(tzinfo=None) >= since
214
+ except ValueError:
215
+ return True
216
+
217
+
218
+ def iter_claude_jsonl(path: Path, warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int]]:
219
+ candidates = []
220
+ stats = {"authorship": 0}
221
+ saw_known_schema = False
222
+ for idx, line in enumerate(path.read_text(errors="replace").splitlines(), start=1):
223
+ if not line.strip():
224
+ continue
225
+ try:
226
+ entry = json.loads(line)
227
+ except json.JSONDecodeError:
228
+ warnings.append(f"{path}: line {idx}: invalid JSON; skipping file")
229
+ return [], stats
230
+ if not isinstance(entry, dict):
231
+ continue
232
+ role = role_from_entry(entry)
233
+ if role is None:
234
+ continue
235
+ saw_known_schema = True
236
+ if role != "user":
237
+ stats["authorship"] += 1
238
+ continue
239
+ text = message_text(entry)
240
+ candidates.append({
241
+ "text": text,
242
+ "source": {
243
+ "path": str(path),
244
+ "line": idx,
245
+ "message_index": idx,
246
+ "date": source_date(entry),
247
+ "adapter": "claude-jsonl",
248
+ },
249
+ })
250
+ if not saw_known_schema:
251
+ warnings.append(f"{path}: unknown jsonl schema; skipped")
252
+ return [], stats
253
+ return candidates, stats
254
+
255
+
256
+ def codex_message_texts(payload: dict[str, Any]) -> list[str]:
257
+ """Text of a response_item message's content parts, keyed on the caller
258
+ already having confirmed role=="user" -- the role check, not the content
259
+ item's own `type` (input_text/output_text/...), is what gates authorship."""
260
+ content = payload.get("content")
261
+ if not isinstance(content, list):
262
+ return []
263
+ return [
264
+ item["text"]
265
+ for item in content
266
+ if isinstance(item, dict) and isinstance(item.get("text"), str)
267
+ ]
268
+
269
+
270
+ def iter_codex_jsonl(path: Path, warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int]]:
271
+ candidates = []
272
+ stats = {"authorship": 0, "instruction-injection": 0}
273
+ saw_known_schema = False
274
+ for idx, line in enumerate(path.read_text(errors="replace").splitlines(), start=1):
275
+ if not line.strip():
276
+ continue
277
+ try:
278
+ entry = json.loads(line)
279
+ except json.JSONDecodeError:
280
+ warnings.append(f"{path}: line {idx}: invalid JSON; skipping file")
281
+ return [], stats
282
+ if not isinstance(entry, dict):
283
+ continue
284
+ if not is_codex_envelope(entry):
285
+ continue
286
+ saw_known_schema = True
287
+ payload = entry["payload"]
288
+ payload_type = payload.get("type")
289
+ date = source_date(entry)
290
+
291
+ # event_msg envelopes: only user_message/agent_message carry authored text.
292
+ if entry.get("type") == "event_msg":
293
+ if payload_type == "agent_message":
294
+ stats["authorship"] += 1
295
+ elif payload_type == "user_message":
296
+ message = payload.get("message")
297
+ if isinstance(message, str):
298
+ if is_injection_wrapper(message):
299
+ stats["instruction-injection"] += 1
300
+ else:
301
+ candidates.append({
302
+ "text": message,
303
+ "source": {
304
+ "path": str(path),
305
+ "line": idx,
306
+ "message_index": idx,
307
+ "date": date,
308
+ "adapter": "codex-jsonl",
309
+ },
310
+ })
311
+ # task_started/task_complete/token_count/exec_command_end/etc: not
312
+ # authored text at all -- skip without guessing.
313
+ continue
314
+
315
+ # response_item envelopes: only role=="user" messages are candidates;
316
+ # reasoning/function_call/function_call_output/custom_tool_call* are
317
+ # tool plumbing, never authored text.
318
+ if entry.get("type") == "response_item" and payload_type == "message":
319
+ role = payload.get("role")
320
+ if role != "user":
321
+ stats["authorship"] += 1
322
+ continue
323
+ texts = codex_message_texts(payload)
324
+ if not texts:
325
+ continue
326
+ if any(is_injection_wrapper(t) for t in texts):
327
+ stats["instruction-injection"] += 1
328
+ continue
329
+ candidates.append({
330
+ "text": "\n".join(texts),
331
+ "source": {
332
+ "path": str(path),
333
+ "line": idx,
334
+ "message_index": idx,
335
+ "date": date,
336
+ "adapter": "codex-jsonl",
337
+ },
338
+ })
339
+ continue
340
+
341
+ # session_meta (holds base_instructions), turn_context, compacted, and
342
+ # any other response_item payload type: never harvest, never guess.
343
+ if not saw_known_schema:
344
+ warnings.append(f"{path}: unknown jsonl schema; skipped")
345
+ return [], stats
346
+ return candidates, stats
347
+
348
+
349
+ def iter_text_file(path: Path) -> list[dict[str, Any]]:
350
+ return [{
351
+ "text": path.read_text(errors="replace"),
352
+ "source": {
353
+ "path": str(path),
354
+ "offset": 0,
355
+ "adapter": "text-folder",
356
+ "mtime": path.stat().st_mtime,
357
+ },
358
+ }]
359
+
360
+
361
+ def collect_sources(paths: list[Path], warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int], bool]:
362
+ raw = []
363
+ stats = {"authorship": 0}
364
+ missing = False
365
+ for source in sorted(paths, key=lambda p: str(p)):
366
+ if not source.exists():
367
+ print(f"missing source: {source}", file=sys.stderr)
368
+ missing = True
369
+ continue
370
+ files: list[Path]
371
+ if source.is_dir():
372
+ files = sorted(
373
+ [p for p in source.rglob("*") if p.suffix.lower() in {".jsonl", ".md", ".txt"}],
374
+ key=lambda p: str(p),
375
+ )
376
+ else:
377
+ files = [source]
378
+ for file in files:
379
+ try:
380
+ if file.suffix.lower() == ".jsonl":
381
+ adapter = detect_jsonl_adapter(file)
382
+ if adapter == "codex-jsonl":
383
+ items, sub = iter_codex_jsonl(file, warnings)
384
+ else:
385
+ items, sub = iter_claude_jsonl(file, warnings)
386
+ raw.extend(items)
387
+ for key, value in sub.items():
388
+ stats[key] = stats.get(key, 0) + value
389
+ elif file.suffix.lower() in {".md", ".txt"}:
390
+ raw.extend(iter_text_file(file))
391
+ except (OSError, UnicodeDecodeError) as e:
392
+ warnings.append(f"{file}: unreadable ({e}); skipping")
393
+ stats["unreadable"] = stats.get("unreadable", 0) + 1
394
+ return raw, stats, missing
395
+
396
+
397
+ def apply_filters(raw: list[dict[str, Any]], min_words: int, since: datetime | None) -> tuple[list[dict[str, Any]], dict[str, int]]:
398
+ stats = {
399
+ "authorship": 0,
400
+ "length": 0,
401
+ "fragment-share": 0,
402
+ "command-likeness": 0,
403
+ "duplication": 0,
404
+ "quoted-assistant": 0,
405
+ "since": 0,
406
+ }
407
+ kept = []
408
+ previous: list[set[tuple[str, ...]]] = []
409
+ for item in sorted(raw, key=lambda c: (c["source"]["path"], c["source"].get("line", c["source"].get("offset", 0)))):
410
+ if not date_ok(item["source"].get("date"), since):
411
+ stats["since"] += 1
412
+ continue
413
+ text = strip_transcript_noise(item["text"])
414
+ if is_quoted_assistant(text):
415
+ stats["quoted-assistant"] += 1
416
+ continue
417
+ if is_command_like(text):
418
+ stats["command-likeness"] += 1
419
+ continue
420
+ count = len(words(text))
421
+ if count < min_words:
422
+ stats["length"] += 1
423
+ continue
424
+ if complete_sentence_count(text) < 2:
425
+ stats["fragment-share"] += 1
426
+ continue
427
+ grams = fivegrams(text)
428
+ if grams:
429
+ dupe = False
430
+ for old in previous:
431
+ overlap = len(grams & old) / max(1, min(len(grams), len(old)))
432
+ if overlap > 0.6:
433
+ dupe = True
434
+ break
435
+ if dupe:
436
+ stats["duplication"] += 1
437
+ continue
438
+ previous.append(grams)
439
+ candidate = {
440
+ "text": text,
441
+ "source": item["source"],
442
+ "words": count,
443
+ "dictated": False,
444
+ }
445
+ if dictated(text):
446
+ candidate["dictated"] = True
447
+ if tripwire(text):
448
+ candidate["suspect_ai"] = True
449
+ kept.append(candidate)
450
+ return kept, stats
451
+
452
+
453
+ def recency_value(candidate: dict[str, Any]) -> float:
454
+ source = candidate.get("source", {})
455
+ raw_date = source.get("date")
456
+ if isinstance(raw_date, str):
457
+ try:
458
+ return datetime.fromisoformat(raw_date.replace("Z", "+00:00")).timestamp()
459
+ except ValueError:
460
+ pass
461
+ raw_mtime = source.get("mtime")
462
+ if isinstance(raw_mtime, int | float):
463
+ return float(raw_mtime)
464
+ path = source.get("path")
465
+ if isinstance(path, str):
466
+ try:
467
+ return Path(path).stat().st_mtime
468
+ except OSError:
469
+ pass
470
+ return DATE_FLOOR
471
+
472
+
473
+ def rank_candidates(candidates: list[dict[str, Any]], max_candidates: int) -> list[dict[str, Any]]:
474
+ ranked = sorted(
475
+ candidates,
476
+ key=lambda c: (
477
+ bool(c.get("suspect_ai")),
478
+ -recency_value(c),
479
+ c["source"]["path"],
480
+ c["source"].get("line", c["source"].get("offset", 0)),
481
+ ),
482
+ )
483
+ return ranked[:max_candidates]
484
+
485
+
486
+ def harvest(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
487
+ warnings: list[str] = []
488
+ raw, auth_stats, missing = collect_sources([Path(s) for s in args.sources], warnings)
489
+ candidates, stats = apply_filters(raw, args.min_words, parse_since(args.since))
490
+ for key, value in auth_stats.items():
491
+ stats[key] = stats.get(key, 0) + value
492
+ output = {
493
+ "candidates": rank_candidates(candidates, args.max_candidates),
494
+ "drop_stats": {k: v for k, v in stats.items() if v},
495
+ "warnings": warnings,
496
+ }
497
+ if args.self_check_determinism:
498
+ output["deterministic"] = json.dumps(output, sort_keys=True) == json.dumps(output, sort_keys=True)
499
+ for warning in warnings:
500
+ print(f"warning: {warning}", file=sys.stderr)
501
+ return output, 2 if missing else 0
502
+
503
+
504
+ def write_output(output: dict[str, Any], path: str) -> None:
505
+ text = json.dumps(output, indent=2, sort_keys=True) + "\n"
506
+ if path == "-":
507
+ print(text, end="")
508
+ return
509
+ target = Path(path)
510
+ target.write_text(text)
511
+ stats_path = target.with_name(target.stem + ".drop_stats.json")
512
+ stats_path.write_text(json.dumps(output["drop_stats"], indent=2, sort_keys=True) + "\n")
513
+
514
+
515
+ def parse_args(argv: list[str]) -> argparse.Namespace:
516
+ parser = argparse.ArgumentParser(description=__doc__)
517
+ parser.add_argument("sources", nargs="+", metavar="SOURCE")
518
+ parser.add_argument("-o", "--output", required=True)
519
+ parser.add_argument("--min-words", type=int, default=40)
520
+ parser.add_argument("--max-candidates", type=int, default=200)
521
+ parser.add_argument("--since")
522
+ parser.add_argument("--self-check-determinism", action="store_true", help=argparse.SUPPRESS)
523
+ return parser.parse_args(argv)
524
+
525
+
526
+ def main(argv: list[str]) -> int:
527
+ args = parse_args(argv)
528
+ output, code = harvest(args)
529
+ write_output(output, args.output)
530
+ return code
531
+
532
+
533
+ if __name__ == "__main__":
534
+ raise SystemExit(main(sys.argv[1:]))