@giamme/forge 0.0.0-stage → 0.9.0

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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,73 @@
1
+ """Post-review annotation of a FAIL.
2
+
3
+ Reads a reviewer's FAIL and says whether it looks sound. It cannot change the verdict,
4
+ and no caller is given a way to. Forge's second invariant is reviewer independence: a
5
+ model that can talk another model out of a FAIL is worth less than no reviewer at all,
6
+ because what it produces is a confident PASS on code nobody checked.
7
+
8
+ So the output is an annotation for a human, and the status file is never touched. The
9
+ asymmetry is deliberate in the other direction too -- nothing here ever annotates a PASS
10
+ into looking suspect, because a PASS that gets merged is the outcome that needs the
11
+ reviewer's judgement left alone.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from . import threshold
18
+ from .client import ask, noul
19
+ from .questions import failure_is_correctness, findings_cite_the_diff
20
+
21
+ # A diff large enough to exceed this is one no reviewer read carefully either. Truncating
22
+ # is honest: the annotation is advisory, and a partial read is flagged in the output.
23
+ DIFF_CHARS = 24000
24
+ REVIEW_CHARS = 12000
25
+
26
+
27
+ def _read(path, limit: int) -> tuple[str, bool]:
28
+ try:
29
+ text = Path(path).read_text(errors='replace')
30
+ except OSError:
31
+ return '', False
32
+ return text[:limit], len(text) > limit
33
+
34
+
35
+ def annotate_failure(*, review_path, diff_path, run_dir=None, config: dict | None = None):
36
+ """Judge a FAIL. Returns None when no judgment could be made.
37
+
38
+ Both questions travel in one request. The result carries `suspect` when either
39
+ probability falls below `gate_warn`, which is the only thing a caller should branch
40
+ on -- and the only branch available is what to print.
41
+ """
42
+ review, review_truncated = _read(review_path, REVIEW_CHARS)
43
+ diff, diff_truncated = _read(diff_path, DIFF_CHARS)
44
+ if not review.strip() or not diff.strip():
45
+ return None
46
+
47
+ questions = dict(correctness=failure_is_correctness(), citation=findings_cite_the_diff())
48
+ state = dict(review=review, diff=diff,
49
+ truncated=dict(review=review_truncated, diff=diff_truncated))
50
+ result = ask(state, questions, site='jev-review-triage', run_dir=run_dir, config=config)
51
+ if result is None:
52
+ return None
53
+
54
+ correctness = noul(result, 'correctness')
55
+ citation = noul(result, 'citation')
56
+ if correctness is None and citation is None:
57
+ return None
58
+
59
+ warn_at = threshold('gate_warn', config=config)
60
+ reasons = []
61
+ citation_bad = citation is not None and citation < warn_at
62
+ if citation_bad:
63
+ reasons.append('the review cites code that may not be in this diff')
64
+ elif correctness is not None and correctness < warn_at:
65
+ # Only when the citations hold up. A review that points at a file the diff does
66
+ # not contain scores low on correctness too, but reporting that as "these are
67
+ # style preferences" describes the wrong problem: nothing can be said about the
68
+ # correctness of code that is not there. Measured on a hallucinated review --
69
+ # citation 0.02, correctness 0.45 -- where both reasons printed and one was wrong.
70
+ reasons.append('the findings look like style preferences rather than correctness bugs')
71
+ return dict(correctness=correctness, citation=citation, suspect=bool(reasons),
72
+ reasons=reasons, truncated=review_truncated or diff_truncated)
73
+
@@ -0,0 +1,271 @@
1
+ """Plan-time difficulty scoring.
2
+
3
+ Jev rates five properties of a task; **this file**, not the model, turns them into a
4
+ tier. That split is the whole design. Weights below can be re-tuned against accrued
5
+ outcomes without spending a single request, because re-weighting replays recorded
6
+ scores rather than re-asking.
7
+
8
+ Nothing here writes to tasks.tsv. Scoring is advisory at best and silent in shadow
9
+ mode; see references/jev.md for how the result reaches (or does not reach) a decision.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from . import enabled, threshold
16
+ from .client import ask, noul, score
17
+ from .questions import (blast_radius, design_judgment, independently_verifiable,
18
+ prompt_adequacy, spec_clarity, state_subtlety, test_coverage)
19
+
20
+ # Weights sum to 1.0. design_judgment carries the most because decompose.md defines
21
+ # difficulty by it first ("rated ... by what the task demands of the model"), and
22
+ # state_subtlety comes second because that same table names concurrency explicitly as a
23
+ # thing that makes a ten-line diff `high`.
24
+ #
25
+ # test_coverage is inverted -- good coverage LOWERS the tier -- and weighted lightly on
26
+ # purpose. Coverage does not reduce the judgment a task demands; it only reduces what a
27
+ # mistake costs. Letting it dominate would route a genuinely hard change to a cheap
28
+ # model because the area happens to be well tested.
29
+ WEIGHTS = dict(design_judgment=0.35, state_subtlety=0.25, blast_radius=0.20,
30
+ spec_clarity=0.15, test_coverage=-0.05)
31
+
32
+ QUESTIONS = dict(design_judgment=design_judgment, state_subtlety=state_subtlety,
33
+ blast_radius=blast_radius, spec_clarity=spec_clarity,
34
+ test_coverage=test_coverage)
35
+
36
+ # Composite cut points. A task exactly on a boundary is the case most likely to be wrong
37
+ # in either direction, so BOUNDARY_BAND either side of a cut escalates a tier: paying
38
+ # for a stronger model on an ambiguous task is the cheap mistake, and under-routing a
39
+ # hard task is the expensive one.
40
+ LOW_MAX = 0.33
41
+ MEDIUM_MAX = 0.63
42
+ BOUNDARY_BAND = 0.05
43
+
44
+ # Below this, the judgment is too uncertain to act on in either direction, so it
45
+ # escalates for the same reason a boundary does.
46
+ CONFIDENCE_FLOOR = 0.55
47
+
48
+ FILE_EXCERPT_CHARS = 600
49
+ MAX_EXCERPT_FILES = 6
50
+ MEMORY_CHARS = 4000
51
+
52
+
53
+ def _normalized(result, qid: str, levels: int):
54
+ """A Score's position mapped onto 0.0-1.0, with its confidence.
55
+
56
+ Score returns a probability-weighted position across ordered levels, so a task
57
+ sitting between 'some' and 'substantial' lands between them rather than being forced
58
+ onto one. Dividing by (levels - 1) makes rubrics with different level counts
59
+ comparable before they are weighted against each other.
60
+ """
61
+ position, confidence = score(result, qid)
62
+ if position is None or levels < 2:
63
+ return None, None
64
+ return max(0.0, min(1.0, float(position) / (levels - 1))), confidence
65
+
66
+
67
+ def tier_for(composite: float) -> str:
68
+ if composite < LOW_MAX:
69
+ return 'low'
70
+ if composite < MEDIUM_MAX:
71
+ return 'medium'
72
+ return 'high'
73
+
74
+
75
+ def _escalate(tier: str) -> str:
76
+ return dict(low='medium', medium='high', high='high')[tier]
77
+
78
+
79
+ def _near_boundary(composite: float) -> bool:
80
+ """Is this close enough to a cut, from BELOW, that it should round up to it?
81
+
82
+ Only from below. A composite just above a cut is already on the higher side of that
83
+ boundary, and escalating it again promotes it a whole tier past the line it was
84
+ merely near -- measured on a real plan, a task at 0.373 sat 0.043 above LOW_MAX,
85
+ scored `medium`, and was escalated to `high`. Rounding up to a boundary is a
86
+ tie-break; jumping the next one is not a tie-break at all.
87
+ """
88
+ # Strictly below: at exactly the cut, tier_for already returns the higher tier.
89
+ return any(0.0 < cut - composite <= BOUNDARY_BAND for cut in (LOW_MAX, MEDIUM_MAX))
90
+
91
+
92
+ def combine(dimensions: dict) -> dict | None:
93
+ """Weighted composite -> tier, plus why it may have been escalated.
94
+
95
+ `dimensions` maps a rubric name to (normalized_position, confidence). A missing
96
+ dimension is not fatal: the remaining weights are renormalized, because a partial
97
+ answer that is honest about being partial beats refusing to say anything.
98
+ """
99
+ usable = {name: value for name, value in dimensions.items()
100
+ if value and value[0] is not None}
101
+ if not usable:
102
+ return None
103
+ total_weight = sum(abs(WEIGHTS[name]) for name in usable)
104
+ if not total_weight:
105
+ return None
106
+
107
+ # test_coverage's weight is negative, so its contribution is inverted here: a
108
+ # well-covered surface pulls the composite down.
109
+ raw = 0.0
110
+ for name, (position, _confidence) in usable.items():
111
+ weight = WEIGHTS[name]
112
+ raw += (1.0 - position) * abs(weight) if weight < 0 else position * weight
113
+ composite = max(0.0, min(1.0, raw / total_weight))
114
+
115
+ # Confidence is weighted by the same weights as the composite, because that is what
116
+ # it is the confidence OF. Measured: taking the minimum instead let one low-weight
117
+ # dimension veto a well-determined answer -- on a real task, blast_radius came back
118
+ # at 0.0 while design_judgment, which carries 0.35, was 0.94, and the minimum
119
+ # reported 0.0 for a composite that was mostly settled. The weakest dimension is
120
+ # still reported separately as `weakest`, since it is the one worth looking at.
121
+ scored = [(name, value) for name, value in usable.items() if value[1] is not None]
122
+ confidence = weakest = None
123
+ if scored:
124
+ weight_sum = sum(abs(WEIGHTS[name]) for name, _ in scored)
125
+ confidence = round(sum(abs(WEIGHTS[name]) * value[1]
126
+ for name, value in scored) / weight_sum, 3)
127
+ weakest = min(scored, key=lambda item: item[1][1])[0]
128
+
129
+ tier = tier_for(composite)
130
+ escalated = None
131
+ if _near_boundary(composite):
132
+ escalated = 'boundary'
133
+ elif confidence is not None and confidence < CONFIDENCE_FLOOR:
134
+ escalated = 'low-confidence'
135
+ if escalated:
136
+ tier = _escalate(tier)
137
+ return dict(tier=tier, composite=round(composite, 3), confidence=confidence,
138
+ weakest=weakest, escalated=escalated, base_tier=tier_for(composite),
139
+ dimensions={name: round(value[0], 3) for name, value in usable.items()},
140
+ missing=sorted(set(WEIGHTS) - set(usable)))
141
+
142
+
143
+ def _excerpt(repo: Path, files: str) -> dict:
144
+ out = {}
145
+ for rel in (files or '').replace(',', ' ').split():
146
+ if len(out) >= MAX_EXCERPT_FILES:
147
+ break
148
+ path = repo / rel
149
+ if not path.is_file():
150
+ # A file the task will CREATE is normal and informative in itself.
151
+ out[rel] = '(does not exist yet)'
152
+ continue
153
+ try:
154
+ out[rel] = path.read_text(errors='replace')[:FILE_EXCERPT_CHARS]
155
+ except OSError:
156
+ continue
157
+ return out
158
+
159
+
160
+ def _memory(repo: Path) -> str:
161
+ path = repo / '.forge' / 'memory.md'
162
+ try:
163
+ return path.read_text(errors='replace')[:MEMORY_CHARS] if path.is_file() else ''
164
+ except OSError:
165
+ return ''
166
+
167
+
168
+ # The other tasks in the plan, as sent to the model. Capped because a large plan would
169
+ # otherwise dominate the payload, and truncated per field for the same reason: the
170
+ # question these answer is "does this task need one of those to land first", which needs
171
+ # their identity and file scope, not their full text.
172
+ MAX_SIBLINGS = 40
173
+ SIBLING_TITLE_CHARS = 120
174
+
175
+
176
+ def _siblings(siblings, task_id: str) -> list[dict]:
177
+ """Compact view of the rest of the plan, excluding the task being scored."""
178
+ out = []
179
+ for sibling in siblings or []:
180
+ if sibling.get('id') == task_id:
181
+ continue
182
+ out.append(dict(id=sibling.get('id', ''),
183
+ title=str(sibling.get('title', ''))[:SIBLING_TITLE_CHARS],
184
+ files=sibling.get('files', ''),
185
+ deps=sibling.get('deps', '')))
186
+ if len(out) >= MAX_SIBLINGS:
187
+ break
188
+ return out
189
+
190
+
191
+ def score_task(repo, *, goal: str, task_id: str, title: str, files: str,
192
+ approach: str = '', prompt: str = '', declared_difficulty: str = '',
193
+ deps: str = '', siblings: list[dict] | None = None,
194
+ run_dir=None, config: dict | None = None) -> dict | None:
195
+ """One request per task. All five rubrics evaluate in parallel server-side.
196
+
197
+ Returns None whenever anything at all goes wrong, because a plan that cannot be
198
+ scored must still be a plan.
199
+ """
200
+ repo_path = Path(repo)
201
+ questions = {name: builder() for name, builder in QUESTIONS.items()}
202
+ # `prompt` is the task's requirements -- tasks/<id>/prompt.md, the text the dwarf is
203
+ # dispatched with and the reviewer reads. It was missing from this state until a real
204
+ # plan was scored, and its absence was not visible from any unit test: every rubric
205
+ # still answered, plausibly, about the one-line title.
206
+ #
207
+ # It mattered most to the gate that exists to catch under-specified tasks. Asked
208
+ # whether "the task states requirements specific enough that a reviewer could judge
209
+ # correctness", with only a title in state, the honest answer is no -- so a 460-word
210
+ # spec with a declared output shape and named edge cases scored 0.23 and tripped its
211
+ # own warning. The gate could not have done anything else; it was never shown the
212
+ # requirements it was asked about.
213
+ # The rest of the plan. independently_verifiable asks whether confirming this task
214
+ # needs "work declared in a different task" -- and until now no different task was
215
+ # ever in state, so the model could only guess whether such a task existed. That is
216
+ # why its two classes overlapped (0.34-0.56 unverifiable against 0.43-0.90
217
+ # verifiable) and no threshold divided them: same failure as prompt.md, a rubric
218
+ # scored against evidence it was never shown.
219
+ plan_tasks = _siblings(siblings, task_id)
220
+ state = dict(goal=goal, task=dict(id=task_id, title=title, files=files, deps=deps),
221
+ prompt=prompt, approach=approach, plan_tasks=plan_tasks,
222
+ file_excerpts=_excerpt(repo_path, files), memory=_memory(repo_path))
223
+ # The planner's own rating is deliberately NOT sent. Jev is here to be a second,
224
+ # independent opinion on the same evidence; showing it the answer first would buy an
225
+ # agreement rate instead of a judgment.
226
+ # Phase 4's plan-time gates need exactly this state, and questions in one request
227
+ # evaluate in parallel, so folding them in here costs no extra round trip -- the
228
+ # alternative is a second request per task carrying an identical payload.
229
+ # (An effort question rode along here until 2026-10-17: it carried no signal beyond the
230
+ # tier -- within-tier AUC 0.50-0.54, 81% of answers xhigh or higher -- and never acted.
231
+ # Effort is now measured the way models are, by exploring pools that name efforts.)
232
+
233
+ gate_ids = {}
234
+ if enabled('gates', config=config):
235
+ gate_ids = dict(prompt_adequacy=prompt_adequacy,
236
+ independently_verifiable=independently_verifiable)
237
+ for name, builder in gate_ids.items():
238
+ questions[name] = builder()
239
+
240
+ result = ask(state, questions, site='jev-score-plan', run_dir=run_dir, config=config)
241
+ if result is None:
242
+ return None
243
+
244
+ dimensions = {}
245
+ for name in QUESTIONS:
246
+ dimensions[name] = _normalized(result, name, len(questions[name]['criteria']))
247
+ combined = combine(dimensions)
248
+ if combined is None:
249
+ return None
250
+ combined.update(id=task_id, declared=declared_difficulty)
251
+
252
+ # Warnings, not gates: recorded below the threshold and silent above it. A LOW
253
+ # probability is what is worth saying out loud here.
254
+ #
255
+ # Each rubric gets its own threshold because they are not on one scale, which is the
256
+ # same trap `tests_act` set earlier. Measured over 15 hand-labelled task prompts:
257
+ # genuinely vague prompts ("add tests", "make it faster") all landed at 0.04 while
258
+ # specific ones ran 0.55-0.87, and task fragments that need a sibling to land first
259
+ # scored 0.11-0.24 against 0.51-0.72 for self-contained work. The shared gate_warn
260
+ # of 0.60 -- which IS right for drift, where it was measured -- would have warned on
261
+ # 5 of 9 and 5 of 11 perfectly good tasks.
262
+ warn_levels = dict(prompt_adequacy=threshold('prompt_warn', config=config),
263
+ independently_verifiable=threshold('verifiable_warn', config=config))
264
+ warnings = {}
265
+ for name in gate_ids:
266
+ probability = noul(result, name)
267
+ if probability is not None and probability < warn_levels[name]:
268
+ warnings[name] = round(probability, 3)
269
+ if warnings:
270
+ combined['warnings'] = warnings
271
+ return combined
@@ -0,0 +1,123 @@
1
+ """Run the tests a change most likely broke, early, in a throwaway copy.
2
+
3
+ Read the deferral note in references/jev.md first: there is no safe window to run tests
4
+ inside a task worktree. An artifact created before the diff is captured reaches QA as if
5
+ the dwarf wrote it; one created after trips the fingerprint re-checks in `do_task` and
6
+ `merge_task`, which write INVALIDATED and block the merge. So this never touches the
7
+ worktree. It exports the reviewed commit to a disposable directory and runs there.
8
+
9
+ **This is early feedback, not verification, and not a speed-up.** The full suite still
10
+ runs at integration exactly as before, so this ADDS a run rather than replacing one. What
11
+ it buys is that a reviewer learns "your diff breaks test_x" while it can still act on
12
+ that, instead of the failure surfacing after the wave. Anyone reading this expecting the
13
+ wall-clock lever the plan described should know the lever was not available: it required
14
+ replacing the full verification, which the fingerprint rules forbid.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from . import threshold
25
+ from .client import ask, noul
26
+ from .questions import test_relevance
27
+
28
+ CANDIDATE_CAP = 100
29
+ HEADER_LINES = 12
30
+
31
+ # Two conditions, not one. Matching "anything under tests/" pulled in README.md, JSON
32
+ # fixtures and benchmark output -- files no runner will execute, each costing a question
33
+ # and a slot under the cap.
34
+ _TEST_PATH_RE = re.compile(r'(^|/)(tests?|spec|specs|__tests__)/'
35
+ r'|(^|/)test_[^/]*$|_test\.[^/]+$|\.(test|spec)\.[^/]+$')
36
+ _CODE_SUFFIXES = ('.py', '.go', '.js', '.jsx', '.ts', '.tsx', '.rb', '.rs', '.java',
37
+ '.kt', '.php', '.sh', '.mjs', '.cjs')
38
+
39
+ # Copied rather than rebuilt when present. Rebuilding a dependency tree per task would
40
+ # cost far more than the feedback is worth; a symlink keeps it near-free.
41
+ _DEP_DIRS = ('node_modules', '.venv', 'venv', 'vendor')
42
+
43
+
44
+ def candidate_tests(repo) -> list[str]:
45
+ """Tracked test files, sorted and capped, from the repo's own index."""
46
+ try:
47
+ out = subprocess.run(['git', '-C', str(repo), 'ls-files', '-z'],
48
+ capture_output=True, text=True, timeout=20)
49
+ except (OSError, subprocess.SubprocessError):
50
+ return []
51
+ if out.returncode != 0:
52
+ return []
53
+ found = sorted(name for name in out.stdout.split('\0')
54
+ if name and name.endswith(_CODE_SUFFIXES) and _TEST_PATH_RE.search(name))
55
+ return found[:CANDIDATE_CAP]
56
+
57
+
58
+ def _header(repo: Path, rel: str) -> str:
59
+ try:
60
+ with (repo / rel).open(errors='replace') as handle:
61
+ return ''.join(next(handle, '') for _ in range(HEADER_LINES))
62
+ except OSError:
63
+ return ''
64
+
65
+
66
+ def select(repo, *, task: str, changed: list, candidates: list, run_dir=None,
67
+ config: dict | None = None):
68
+ """The tests worth running for this change, most likely first.
69
+
70
+ Returns None when no judgment could be made. The threshold is `tests_act`'s sibling
71
+ `gate_warn` deliberately NOT reused here -- see the caller, which uses the measured
72
+ per-repo value when one exists and otherwise runs nothing rather than guess.
73
+ """
74
+ candidates = [c for c in (candidates or []) if c]
75
+ if not candidates or not (task or '').strip():
76
+ return None
77
+ repo_path = Path(repo)
78
+ questions = {f't{i}': test_relevance(name, _header(repo_path, name))
79
+ for i, name in enumerate(candidates)}
80
+ state = dict(task=task, changed_files=list(changed or []))
81
+ result = ask(state, questions, site='jev-test-subset', run_dir=run_dir, config=config)
82
+ if result is None:
83
+ return None
84
+ scored = []
85
+ for i, name in enumerate(candidates):
86
+ probability = noul(result, f't{i}')
87
+ if probability is not None:
88
+ scored.append((probability, name))
89
+ scored.sort(reverse=True)
90
+ return scored
91
+
92
+
93
+ def export(repo, commit: str, destination) -> bool:
94
+ """Copy one commit's tree into a throwaway directory. Never touches the worktree."""
95
+ destination = Path(destination)
96
+ try:
97
+ destination.mkdir(parents=True, exist_ok=True)
98
+ archive = subprocess.run(['git', '-C', str(repo), 'archive', commit],
99
+ capture_output=True, timeout=120)
100
+ if archive.returncode != 0:
101
+ return False
102
+ extract = subprocess.run(['tar', '-x', '-C', str(destination)],
103
+ input=archive.stdout, capture_output=True, timeout=120)
104
+ if extract.returncode != 0:
105
+ return False
106
+ except (OSError, subprocess.SubprocessError):
107
+ return False
108
+
109
+ # Dependencies are not in the tree. Link them so the export can actually run; a
110
+ # missing link simply means the command fails and the caller reports nothing.
111
+ for name in _DEP_DIRS:
112
+ source = Path(repo) / name
113
+ target = destination / name
114
+ if source.is_dir() and not target.exists():
115
+ try:
116
+ os.symlink(source, target)
117
+ except OSError:
118
+ pass
119
+ return True
120
+
121
+
122
+ def cleanup(destination) -> None:
123
+ shutil.rmtree(str(destination), ignore_errors=True)