@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.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"""Small CLI; every subcommand must work with no key configured and no network."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
from forge_registry import register_run_dir, runs_registry
|
|
15
|
+
|
|
16
|
+
from . import (CAPABILITIES, CLEF_ONLY_SITES, CLOUDFLARE_DEFAULT_SITES, CLOUDFLARE_TOKEN_FILE,
|
|
17
|
+
DEFAULT_THRESHOLDS, PROVIDERS, RETIRED_THRESHOLDS, SITE_CAPABILITY, THRESHOLD_FALLBACK, api_key,
|
|
18
|
+
capability_ready, cloudflare, config_path, enabled, load_config, provider_for, provider_ready,
|
|
19
|
+
redact, save_config, secrets, site_ready)
|
|
20
|
+
from . import client, questions
|
|
21
|
+
|
|
22
|
+
PRIVACY_STATEMENT = """\
|
|
23
|
+
Jev (TypeSafe System One) sends task prompts, diff hunks, file excerpts, test names,
|
|
24
|
+
and memory entries to an external API (api.typesafe.ai) to get back structured
|
|
25
|
+
judgments. Anything under .forge/ or .git is never sent. Your API key is stored at
|
|
26
|
+
{path} with file mode 0600, and is never written into a run directory, a prompt, or a
|
|
27
|
+
log.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
CLOUDFLARE_PRIVACY = """\
|
|
31
|
+
Sites mapped to Cloudflare send the same evidence Jev gets -- task prompts, diff hunks,
|
|
32
|
+
file excerpts and memory entries -- to Cloudflare Workers AI (api.cloudflare.com), where
|
|
33
|
+
Cloudflare's hosted Clef answers. Cloudflare states it does not store or train on these
|
|
34
|
+
requests. The token is read from {token_file} (mode 0600) or CLOUDFLARE_API_TOKEN, and is
|
|
35
|
+
never written into a run directory, a prompt or a log. It needs only Account > Workers AI
|
|
36
|
+
> Read.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_CAP_ENV = dict(routing='FORGE_JEV_ROUTING', tests='FORGE_JEV_TESTS',
|
|
40
|
+
gates='FORGE_JEV_GATES', memory='FORGE_JEV_MEMORY')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parser() -> argparse.ArgumentParser:
|
|
44
|
+
p = argparse.ArgumentParser(prog='forge jev', description='Opt-in TypeSafe/Jev integration for Forge')
|
|
45
|
+
sub = p.add_subparsers(dest='command', required=True)
|
|
46
|
+
|
|
47
|
+
setup = sub.add_parser('setup')
|
|
48
|
+
setup.add_argument('--key')
|
|
49
|
+
setup.add_argument('--yes', action='store_true')
|
|
50
|
+
setup.add_argument('--provider', choices=PROVIDERS, default='typesafe')
|
|
51
|
+
setup.add_argument('--account-id', help='Cloudflare account id (else CLOUDFLARE_ACCOUNT_ID)')
|
|
52
|
+
setup.add_argument('--model', choices=('clef', 'clef-flash'), default='clef')
|
|
53
|
+
setup.add_argument('--sites', help='comma-separated sites to send to Cloudflare (default: the measured ones)')
|
|
54
|
+
|
|
55
|
+
first = sub.add_parser('first-pass', help=argparse.SUPPRESS)
|
|
56
|
+
first.add_argument('--plan', required=True)
|
|
57
|
+
first.add_argument('--repo', required=True)
|
|
58
|
+
retry = sub.add_parser('retry-pass', help=argparse.SUPPRESS)
|
|
59
|
+
retry.add_argument('--plan', required=True)
|
|
60
|
+
retry.add_argument('--repo', required=True)
|
|
61
|
+
retry.add_argument('--task', required=True)
|
|
62
|
+
retry.add_argument('--attempt', type=int, required=True)
|
|
63
|
+
|
|
64
|
+
sites = sub.add_parser('sites', help='show or set which provider answers each site')
|
|
65
|
+
sites.add_argument('assign', nargs='*', metavar='SITE=PROVIDER')
|
|
66
|
+
|
|
67
|
+
for name in ('enable', 'disable'):
|
|
68
|
+
command = sub.add_parser(name)
|
|
69
|
+
command.add_argument('--capability', choices=CAPABILITIES, action='append', default=[])
|
|
70
|
+
|
|
71
|
+
status = sub.add_parser('status')
|
|
72
|
+
status.add_argument('--json', action='store_true')
|
|
73
|
+
|
|
74
|
+
doctor = sub.add_parser('doctor')
|
|
75
|
+
doctor.add_argument('--live', action='store_true')
|
|
76
|
+
doctor.add_argument('--json', action='store_true')
|
|
77
|
+
|
|
78
|
+
backtest = sub.add_parser('backtest')
|
|
79
|
+
backtest.add_argument('--repo', required=True)
|
|
80
|
+
backtest.add_argument('--limit', type=int, default=None)
|
|
81
|
+
backtest.add_argument('--execute', action='store_true')
|
|
82
|
+
backtest.add_argument('--max-requests', type=int, default=200)
|
|
83
|
+
backtest.add_argument('--out', default=None)
|
|
84
|
+
backtest.add_argument('--capability', choices=('tests', 'drift'), default='tests')
|
|
85
|
+
backtest.add_argument('--json', action='store_true')
|
|
86
|
+
|
|
87
|
+
verify_discover = sub.add_parser('verify-discover')
|
|
88
|
+
verify_discover.add_argument('--repo', required=True)
|
|
89
|
+
verify_discover.add_argument('--json', action='store_true')
|
|
90
|
+
verify_discover.add_argument('--run-dir')
|
|
91
|
+
|
|
92
|
+
score_plan = sub.add_parser('score-plan')
|
|
93
|
+
score_plan.add_argument('--plan', required=True)
|
|
94
|
+
score_plan.add_argument('--repo', required=True)
|
|
95
|
+
score_plan.add_argument('--json', action='store_true')
|
|
96
|
+
|
|
97
|
+
coupling = sub.add_parser('coupling')
|
|
98
|
+
coupling.add_argument('--plan', required=True)
|
|
99
|
+
coupling.add_argument('--repo', required=True)
|
|
100
|
+
coupling.add_argument('--json', action='store_true')
|
|
101
|
+
|
|
102
|
+
review_triage = sub.add_parser('review-triage')
|
|
103
|
+
review_triage.add_argument('--review', required=True)
|
|
104
|
+
review_triage.add_argument('--diff', required=True)
|
|
105
|
+
review_triage.add_argument('--run-dir', default=None)
|
|
106
|
+
review_triage.add_argument('--json', action='store_true')
|
|
107
|
+
|
|
108
|
+
memory_curate = sub.add_parser('memory-curate')
|
|
109
|
+
memory_curate.add_argument('--repo', required=True)
|
|
110
|
+
memory_curate.add_argument('--category', required=True)
|
|
111
|
+
memory_curate.add_argument('--text', required=True)
|
|
112
|
+
memory_curate.add_argument('--json', action='store_true')
|
|
113
|
+
# Without this, every curation call is invisible: no jev.jsonl line, no latency, no
|
|
114
|
+
# tokens. A first real run made 51 logged calls and an unknown number of unlogged
|
|
115
|
+
# memory ones, which is exactly the accounting a run log exists to prevent.
|
|
116
|
+
memory_curate.add_argument('--run-dir', default=None)
|
|
117
|
+
|
|
118
|
+
memory_slice = sub.add_parser('memory-slice')
|
|
119
|
+
memory_slice.add_argument('--task', required=True)
|
|
120
|
+
memory_slice.add_argument('--lines', required=True)
|
|
121
|
+
memory_slice.add_argument('--keep', type=int, default=None)
|
|
122
|
+
memory_slice.add_argument('--run-dir', default=None)
|
|
123
|
+
|
|
124
|
+
memory_stale = sub.add_parser('memory-stale')
|
|
125
|
+
memory_stale.add_argument('--text', required=True)
|
|
126
|
+
memory_stale.add_argument('--anchor', required=True)
|
|
127
|
+
memory_stale.add_argument('--run-dir', default=None)
|
|
128
|
+
|
|
129
|
+
test_subset = sub.add_parser('test-subset')
|
|
130
|
+
test_subset.add_argument('--repo', required=True)
|
|
131
|
+
test_subset.add_argument('--commit', required=True)
|
|
132
|
+
test_subset.add_argument('--task', required=True)
|
|
133
|
+
test_subset.add_argument('--command', dest='subset_command', required=True)
|
|
134
|
+
test_subset.add_argument('--changed', default=None)
|
|
135
|
+
test_subset.add_argument('--base', default=None)
|
|
136
|
+
test_subset.add_argument('--run-dir', default=None)
|
|
137
|
+
test_subset.add_argument('--max', type=int, default=8)
|
|
138
|
+
test_subset.add_argument('--timeout', type=int, default=600)
|
|
139
|
+
|
|
140
|
+
verify_triage = sub.add_parser('verify-triage')
|
|
141
|
+
verify_triage.add_argument('--repo', required=True)
|
|
142
|
+
# dest is deliberately not 'command': the top-level subparsers already own that dest
|
|
143
|
+
# name for the subcommand itself ('verify-triage'), and letting --command collide
|
|
144
|
+
# with it would overwrite the dispatch key in main().
|
|
145
|
+
verify_triage.add_argument('--command', dest='verify_command', required=True)
|
|
146
|
+
verify_triage.add_argument('--log', required=True)
|
|
147
|
+
verify_triage.add_argument('--json', action='store_true')
|
|
148
|
+
verify_triage.add_argument('--run-dir')
|
|
149
|
+
return p
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_key_from_tty(prompt: str = 'TypeSafe API key: ') -> str | None:
|
|
153
|
+
# Probe /dev/tty first: getpass falls back to stdin with a warning when it cannot
|
|
154
|
+
# open the terminal itself, and a background worker must never block there.
|
|
155
|
+
try:
|
|
156
|
+
tty = open('/dev/tty', 'r')
|
|
157
|
+
except OSError:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
if not _isatty(tty):
|
|
161
|
+
return None
|
|
162
|
+
finally:
|
|
163
|
+
tty.close()
|
|
164
|
+
return getpass.getpass(prompt, stream=sys.stderr)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _isatty(tty) -> bool:
|
|
168
|
+
try:
|
|
169
|
+
return os.isatty(tty.fileno())
|
|
170
|
+
except OSError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _probe(config: dict, provider: str = 'typesafe') -> tuple[bool, str | None]:
|
|
175
|
+
config = dict(config, sites=dict(config.get('sites') or {}, **{'setup-probe': provider}))
|
|
176
|
+
with tempfile.TemporaryDirectory() as run_dir:
|
|
177
|
+
result = client.ask(questions.PROBE_STATE, questions.PROBE, site='setup-probe',
|
|
178
|
+
run_dir=run_dir, config=config, deadline_s=config['deadline_s'])
|
|
179
|
+
if result is not None:
|
|
180
|
+
return True, None
|
|
181
|
+
log = Path(run_dir) / 'jev.jsonl'
|
|
182
|
+
reason = None
|
|
183
|
+
if log.exists():
|
|
184
|
+
lines = log.read_text().splitlines()
|
|
185
|
+
if lines:
|
|
186
|
+
reason = json.loads(lines[-1]).get('reason')
|
|
187
|
+
return False, reason
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _setup_cloudflare(args) -> int:
|
|
191
|
+
token_file = config_path().parent / CLOUDFLARE_TOKEN_FILE
|
|
192
|
+
print(CLOUDFLARE_PRIVACY.format(token_file=token_file))
|
|
193
|
+
config = load_config()
|
|
194
|
+
entry = dict((config.get('providers') or {}).get('cloudflare') or {}, model=args.model)
|
|
195
|
+
account = args.account_id or os.environ.get('CLOUDFLARE_ACCOUNT_ID') or entry.get('account_id')
|
|
196
|
+
if not account:
|
|
197
|
+
print('forge jev: no Cloudflare account id; pass --account-id or set CLOUDFLARE_ACCOUNT_ID.', file=sys.stderr)
|
|
198
|
+
return 2
|
|
199
|
+
entry['account_id'] = account
|
|
200
|
+
config['providers'] = dict(config.get('providers') or {}, cloudflare=entry)
|
|
201
|
+
if not (cloudflare(config) or {}).get('token'):
|
|
202
|
+
token = _read_key_from_tty('Cloudflare API token: ')
|
|
203
|
+
if not token:
|
|
204
|
+
print('No token in CLOUDFLARE_API_TOKEN or %s, and no terminal to prompt on.' % token_file)
|
|
205
|
+
return 0
|
|
206
|
+
token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
fd = os.open(str(token_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
208
|
+
with os.fdopen(fd, 'w') as handle:
|
|
209
|
+
handle.write('CLOUDFLARE_API_TOKEN=%s\n' % token)
|
|
210
|
+
os.chmod(token_file, 0o600)
|
|
211
|
+
chosen = [s for s in (args.sites.split(',') if args.sites else CLOUDFLARE_DEFAULT_SITES) if s]
|
|
212
|
+
unknown = [s for s in chosen if s not in SITE_CAPABILITY or s in CLEF_ONLY_SITES]
|
|
213
|
+
if unknown:
|
|
214
|
+
print('forge jev: unknown site(s): ' + ', '.join(unknown), file=sys.stderr)
|
|
215
|
+
return 2
|
|
216
|
+
if not os.environ.get('FORGE_JEV_FIXTURES'):
|
|
217
|
+
ok, reason = _probe(config, 'cloudflare')
|
|
218
|
+
if not ok:
|
|
219
|
+
print('forge jev: Cloudflare did not answer the probe (' + redact(str(reason), *secrets(config)) + ')',
|
|
220
|
+
file=sys.stderr)
|
|
221
|
+
return 3
|
|
222
|
+
config['sites'] = dict(config.get('sites') or {}, **{s: 'cloudflare' for s in chosen})
|
|
223
|
+
config['enabled'] = True
|
|
224
|
+
save_config(config)
|
|
225
|
+
print('Cloudflare (%s) answers: %s' % (args.model, ', '.join(chosen) or 'no site yet'))
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _watchdog(seconds: float) -> None:
|
|
230
|
+
"""Exit this process, whatever it is doing, `seconds` from now. A separate CLI process
|
|
231
|
+
with no worker threads of its own to protect, so SIGALRM is safe here (unlike in
|
|
232
|
+
Fractal's threads); the shell hooks that call it then never wait past the bound."""
|
|
233
|
+
import signal
|
|
234
|
+
|
|
235
|
+
signal.signal(signal.SIGALRM, lambda *_: os._exit(3))
|
|
236
|
+
signal.setitimer(signal.ITIMER_REAL, seconds)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_first_pass(args) -> int:
|
|
240
|
+
"""Silent first-attempt predictions for a freshly planned table (forge_jev/predict.py)."""
|
|
241
|
+
from . import predict
|
|
242
|
+
config = load_config()
|
|
243
|
+
if not site_ready('clef-first-pass', config):
|
|
244
|
+
return 3
|
|
245
|
+
_watchdog(2 * config.get('deadline_s', 5.0) + 5)
|
|
246
|
+
return predict.main_first_pass(args)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_retry_pass(args) -> int:
|
|
250
|
+
"""Silent prediction for a dwarf attempt above 1 (forge_jev/predict.py)."""
|
|
251
|
+
from . import predict
|
|
252
|
+
config = load_config()
|
|
253
|
+
if not site_ready('clef-retry-pass', config):
|
|
254
|
+
return 3
|
|
255
|
+
_watchdog(2 * config.get('deadline_s', 5.0) + 5)
|
|
256
|
+
return predict.main_retry_pass(args)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_sites(args) -> int:
|
|
260
|
+
config = load_config()
|
|
261
|
+
if args.assign:
|
|
262
|
+
for item in args.assign:
|
|
263
|
+
site, _, provider = item.partition('=')
|
|
264
|
+
if site not in SITE_CAPABILITY or site in CLEF_ONLY_SITES or provider not in PROVIDERS:
|
|
265
|
+
print('forge jev sites: expected SITE=PROVIDER with SITE one of %s and PROVIDER one of %s; got %r'
|
|
266
|
+
% (', '.join(s for s in SITE_CAPABILITY if s not in CLEF_ONLY_SITES), ', '.join(PROVIDERS), item),
|
|
267
|
+
file=sys.stderr)
|
|
268
|
+
return 2
|
|
269
|
+
config['sites'][site] = provider
|
|
270
|
+
save_config(config)
|
|
271
|
+
for site in SITE_CAPABILITY:
|
|
272
|
+
if site in CLEF_ONLY_SITES and cloudflare(config) is None:
|
|
273
|
+
continue
|
|
274
|
+
print('%-20s %-10s %s' % (site, provider_for(site, config), 'ready' if site_ready(site, config) else 'not ready'))
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def cmd_setup(args) -> int:
|
|
279
|
+
if args.provider == 'cloudflare':
|
|
280
|
+
return _setup_cloudflare(args)
|
|
281
|
+
path = config_path()
|
|
282
|
+
print(PRIVACY_STATEMENT.format(path=path))
|
|
283
|
+
key = args.key
|
|
284
|
+
if not key:
|
|
285
|
+
key = _read_key_from_tty()
|
|
286
|
+
if not key:
|
|
287
|
+
print('No key given and no controlling terminal to prompt on. Run again with --key.')
|
|
288
|
+
return 0
|
|
289
|
+
config = load_config()
|
|
290
|
+
config['key'] = key
|
|
291
|
+
if not os.environ.get('FORGE_JEV_FIXTURES'):
|
|
292
|
+
ok, reason = _probe(config)
|
|
293
|
+
if not ok:
|
|
294
|
+
if reason and reason.startswith('http-401'):
|
|
295
|
+
print('forge jev: TypeSafe rejected the key.', file=sys.stderr)
|
|
296
|
+
else:
|
|
297
|
+
print('forge jev: could not validate the key (' + redact(str(reason), key) + ')', file=sys.stderr)
|
|
298
|
+
return 3
|
|
299
|
+
config['enabled'] = True
|
|
300
|
+
save_config(config)
|
|
301
|
+
print('Jev enabled. Capabilities: ' + ', '.join(c for c in CAPABILITIES if config['capabilities'].get(c, True)))
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_enable(args) -> int:
|
|
306
|
+
config = load_config()
|
|
307
|
+
if args.capability:
|
|
308
|
+
for name in args.capability:
|
|
309
|
+
config['capabilities'][name] = True
|
|
310
|
+
else:
|
|
311
|
+
config['enabled'] = True
|
|
312
|
+
save_config(config)
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_disable(args) -> int:
|
|
317
|
+
config = load_config()
|
|
318
|
+
if args.capability:
|
|
319
|
+
for name in args.capability:
|
|
320
|
+
config['capabilities'][name] = False
|
|
321
|
+
else:
|
|
322
|
+
config['enabled'] = False
|
|
323
|
+
save_config(config)
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_status(args) -> int:
|
|
328
|
+
config = load_config()
|
|
329
|
+
overrides = {var: os.environ[var] for var in ('FORGE_JEV', 'FORGE_JEV_ACT', 'FORGE_JEV_SHADOW',
|
|
330
|
+
*_CAP_ENV.values()) if var in os.environ}
|
|
331
|
+
overrides.update({var: os.environ[var] for var in ('FORGE_CLEF', 'FORGE_JEV_PROVIDER', 'FORGE_JEV_OUTCOMES')
|
|
332
|
+
if var in os.environ})
|
|
333
|
+
remote = cloudflare(config)
|
|
334
|
+
# Offline by contract: the shell's forge_jev_active reads this before every Jev step.
|
|
335
|
+
data = dict(config_path=str(config_path()), key_present=api_key(config) is not None,
|
|
336
|
+
enabled=enabled(config=config),
|
|
337
|
+
capabilities={c: enabled(c, config=config) for c in CAPABILITIES},
|
|
338
|
+
capability_ready={c: capability_ready(c, config) for c in CAPABILITIES},
|
|
339
|
+
providers=dict(typesafe=dict(ready=provider_ready('typesafe', config)),
|
|
340
|
+
cloudflare=dict(configured=remote is not None,
|
|
341
|
+
ready=provider_ready('cloudflare', config),
|
|
342
|
+
model=remote['model'] if remote else None)),
|
|
343
|
+
sites={s: dict(provider=provider_for(s, config), ready=site_ready(s, config))
|
|
344
|
+
for s in SITE_CAPABILITY},
|
|
345
|
+
thresholds=config['thresholds'], model_thresholds=config.get('model_thresholds') or {},
|
|
346
|
+
env_overrides=overrides)
|
|
347
|
+
if args.json:
|
|
348
|
+
print(json.dumps(data, indent=2))
|
|
349
|
+
else:
|
|
350
|
+
print('config: ' + data['config_path'])
|
|
351
|
+
print('key present: ' + str(data['key_present']))
|
|
352
|
+
print('enabled: ' + str(data['enabled']))
|
|
353
|
+
for name, state in data['capabilities'].items():
|
|
354
|
+
print(f' {name}: {state}')
|
|
355
|
+
cf = data['providers']['cloudflare']
|
|
356
|
+
if cf['configured']:
|
|
357
|
+
print('cloudflare: ready=%s model=%s' % (cf['ready'], cf['model']))
|
|
358
|
+
moved = {s: v for s, v in data['sites'].items()
|
|
359
|
+
if v['provider'] != 'typesafe' and (cf['configured'] or s not in CLEF_ONLY_SITES)}
|
|
360
|
+
for site, value in sorted(moved.items()):
|
|
361
|
+
print(f" {site}: {value['provider']}{'' if value['ready'] else ' (not ready)'}")
|
|
362
|
+
print('thresholds: ' + json.dumps(data['thresholds']))
|
|
363
|
+
if overrides:
|
|
364
|
+
print('env overrides: ' + ', '.join(f'{k}={v}' for k, v in overrides.items()))
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_doctor(args) -> int:
|
|
369
|
+
checks = []
|
|
370
|
+
config = load_config()
|
|
371
|
+
path = config_path()
|
|
372
|
+
if path.exists():
|
|
373
|
+
try:
|
|
374
|
+
json.loads(path.read_text())
|
|
375
|
+
checks.append(('config readable', True, None))
|
|
376
|
+
except (OSError, ValueError) as error:
|
|
377
|
+
checks.append(('config readable', False, str(error)))
|
|
378
|
+
mode = path.stat().st_mode & 0o777
|
|
379
|
+
checks.append(('config mode 0600', mode == 0o600, None if mode == 0o600 else oct(mode)))
|
|
380
|
+
else:
|
|
381
|
+
checks.append(('config readable', True, 'no config file; using defaults'))
|
|
382
|
+
key = api_key(config)
|
|
383
|
+
# The Clef-only sites count once a Cloudflare provider is configured, not before:
|
|
384
|
+
# a TypeSafe-only setup is as ready as it ever was.
|
|
385
|
+
used = {provider_for(s, config) for s in SITE_CAPABILITY
|
|
386
|
+
if enabled(SITE_CAPABILITY[s], config=config) and s not in CLEF_ONLY_SITES}
|
|
387
|
+
if cloudflare(config) is not None:
|
|
388
|
+
used.add('cloudflare')
|
|
389
|
+
if 'typesafe' in used or not used:
|
|
390
|
+
checks.append(('key present', key is not None, None))
|
|
391
|
+
if 'cloudflare' in used:
|
|
392
|
+
remote = cloudflare(config) or {}
|
|
393
|
+
checks.append(('cloudflare credentials', provider_ready('cloudflare', config),
|
|
394
|
+
None if provider_ready('cloudflare', config) else
|
|
395
|
+
'missing ' + ' and '.join(n for n, v in (('token', remote.get('token')),
|
|
396
|
+
('account id', remote.get('account_id'))) if not v)))
|
|
397
|
+
parsed = urllib.parse.urlparse(config['endpoint'])
|
|
398
|
+
checks.append(('endpoint parseable', bool(parsed.scheme and parsed.netloc), None))
|
|
399
|
+
checks.append(('urllib importable', True, None))
|
|
400
|
+
|
|
401
|
+
# A threshold in the config that no rubric reads. The config is frozen at first run,
|
|
402
|
+
# so a key that was renamed since stays in the file forever and `status` keeps
|
|
403
|
+
# printing it -- which invites tuning a number that changes nothing. Also catches a
|
|
404
|
+
# typo in a hand-edited config, where the symptom is otherwise silence.
|
|
405
|
+
held = set(config.get('thresholds') or ())
|
|
406
|
+
unknown = sorted(held - set(DEFAULT_THRESHOLDS) - set(THRESHOLD_FALLBACK) - RETIRED_THRESHOLDS)
|
|
407
|
+
checks.append(('thresholds all known', not unknown,
|
|
408
|
+
None if not unknown else 'not read by anything: ' + ', '.join(unknown)))
|
|
409
|
+
retired = sorted(held & RETIRED_THRESHOLDS)
|
|
410
|
+
if retired:
|
|
411
|
+
checks.append(('retired thresholds', True, 'no longer read, safe to delete: ' + ', '.join(retired)))
|
|
412
|
+
|
|
413
|
+
# Two warnings rather than blockers: a loose mode is worth fixing but does not stop
|
|
414
|
+
# Jev working, and an unread threshold key is inert by definition.
|
|
415
|
+
_warnings = ('config mode 0600', 'thresholds all known')
|
|
416
|
+
ready = all(ok for name, ok, _ in checks if name not in _warnings)
|
|
417
|
+
|
|
418
|
+
if args.live:
|
|
419
|
+
for provider in sorted(used or {'typesafe'}):
|
|
420
|
+
label = 'live probe' if provider == 'typesafe' else 'live probe (%s)' % provider
|
|
421
|
+
if not provider_ready(provider, config):
|
|
422
|
+
checks.append((label, False, 'no credentials configured'))
|
|
423
|
+
ready = False
|
|
424
|
+
else:
|
|
425
|
+
ok, reason = _probe(config, provider)
|
|
426
|
+
checks.append((label, ok, reason))
|
|
427
|
+
ready = ready and ok
|
|
428
|
+
|
|
429
|
+
data = dict(ready=ready,
|
|
430
|
+
checks=[dict(name=n, ok=o, warning=n in _warnings,
|
|
431
|
+
detail=redact(str(d), *secrets(config)) if d else None)
|
|
432
|
+
for n, o, d in checks])
|
|
433
|
+
if args.json:
|
|
434
|
+
print(json.dumps(data, indent=2))
|
|
435
|
+
else:
|
|
436
|
+
for check in data['checks']:
|
|
437
|
+
# A failed warning prints `warn`, not `FAIL`: the line below says `ready`,
|
|
438
|
+
# and one report cannot say both.
|
|
439
|
+
mark = 'ok' if check['ok'] else ('warn' if check['warning'] else 'FAIL')
|
|
440
|
+
line = f"[{mark}] {check['name']}"
|
|
441
|
+
if check['detail']:
|
|
442
|
+
line += ': ' + check['detail']
|
|
443
|
+
print(line)
|
|
444
|
+
print('ready' if ready else 'not ready')
|
|
445
|
+
return 0 if ready else 3
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_backtest(args) -> int:
|
|
449
|
+
repo = Path(args.repo)
|
|
450
|
+
if not repo.is_dir() or not (repo / '.git').exists():
|
|
451
|
+
print(f'forge jev backtest: --repo {args.repo!r} is not a git repository', file=sys.stderr)
|
|
452
|
+
return 2
|
|
453
|
+
|
|
454
|
+
# Imported lazily so cli.py keeps working (and status/doctor keep paying nothing)
|
|
455
|
+
# even while backtest.py is mid-edit by another agent, and so a missing module is a
|
|
456
|
+
# clean exit rather than a traceback.
|
|
457
|
+
try:
|
|
458
|
+
from . import backtest
|
|
459
|
+
except ImportError as error:
|
|
460
|
+
print('forge jev backtest: backtest module unavailable (' + str(error) + ')', file=sys.stderr)
|
|
461
|
+
return 3
|
|
462
|
+
|
|
463
|
+
config = load_config()
|
|
464
|
+
if args.execute and api_key(config) is None:
|
|
465
|
+
print('forge jev backtest: no API key configured. Run `forge jev setup` first.', file=sys.stderr)
|
|
466
|
+
return 3
|
|
467
|
+
|
|
468
|
+
if not args.execute:
|
|
469
|
+
print('Dry estimate only -- no API call was made (pass --execute to run one).', file=sys.stderr)
|
|
470
|
+
|
|
471
|
+
summary = backtest.run(repo, limit=args.limit, execute=args.execute,
|
|
472
|
+
max_requests=args.max_requests, out_dir=args.out,
|
|
473
|
+
capability=args.capability, config=config)
|
|
474
|
+
if args.json:
|
|
475
|
+
print(json.dumps(summary, indent=2))
|
|
476
|
+
else:
|
|
477
|
+
print(backtest.report(summary))
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def cmd_verify_discover(args) -> int:
|
|
482
|
+
repo = Path(args.repo)
|
|
483
|
+
if not repo.is_dir():
|
|
484
|
+
print(f'forge jev verify-discover: --repo {args.repo!r} is not a directory', file=sys.stderr)
|
|
485
|
+
return 2
|
|
486
|
+
|
|
487
|
+
# Imported lazily so cli.py keeps working, and status/doctor keep paying nothing,
|
|
488
|
+
# even while verify.py is mid-edit -- same reasoning as cmd_backtest's import.
|
|
489
|
+
try:
|
|
490
|
+
from . import verify
|
|
491
|
+
except ImportError as error:
|
|
492
|
+
print('forge jev verify-discover: verify module unavailable (' + str(error) + ')', file=sys.stderr)
|
|
493
|
+
return 3
|
|
494
|
+
|
|
495
|
+
config = load_config()
|
|
496
|
+
result = verify.discover(repo, config=config, run_dir=args.run_dir)
|
|
497
|
+
if result is None:
|
|
498
|
+
if not args.json:
|
|
499
|
+
print('forge jev verify-discover: no verified candidate found', file=sys.stderr)
|
|
500
|
+
else:
|
|
501
|
+
print(json.dumps(None))
|
|
502
|
+
return 3
|
|
503
|
+
if args.json:
|
|
504
|
+
print(json.dumps(result, indent=2))
|
|
505
|
+
else:
|
|
506
|
+
# stdout carries only the command, deliberately, so the bash caller can capture
|
|
507
|
+
# it with $(...); everything explanatory goes to stderr instead.
|
|
508
|
+
print(result['command'])
|
|
509
|
+
print(f"forge jev verify-discover: picked {result['command']!r} "
|
|
510
|
+
f"(confidence {result['confidence']:.2f}, source {result['source']})", file=sys.stderr)
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def cmd_verify_triage(args) -> int:
|
|
515
|
+
repo = Path(args.repo)
|
|
516
|
+
if not repo.is_dir():
|
|
517
|
+
print(f'forge jev verify-triage: --repo {args.repo!r} is not a directory', file=sys.stderr)
|
|
518
|
+
return 2
|
|
519
|
+
try:
|
|
520
|
+
log_tail = Path(args.log).read_text(errors='replace')
|
|
521
|
+
except OSError as error:
|
|
522
|
+
print(f'forge jev verify-triage: cannot read --log {args.log!r}: {error}', file=sys.stderr)
|
|
523
|
+
return 2
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
from . import verify
|
|
527
|
+
except ImportError as error:
|
|
528
|
+
print('forge jev verify-triage: verify module unavailable (' + str(error) + ')', file=sys.stderr)
|
|
529
|
+
return 3
|
|
530
|
+
|
|
531
|
+
config = load_config()
|
|
532
|
+
traps = verify.known_traps(repo)
|
|
533
|
+
result = verify.triage(repo, command=args.verify_command, log_tail=log_tail, traps=traps, config=config,
|
|
534
|
+
run_dir=args.run_dir)
|
|
535
|
+
if result is None:
|
|
536
|
+
if args.json:
|
|
537
|
+
print(json.dumps(None))
|
|
538
|
+
else:
|
|
539
|
+
print('forge jev verify-triage: no judgment produced', file=sys.stderr)
|
|
540
|
+
return 3
|
|
541
|
+
if args.json:
|
|
542
|
+
print(json.dumps(result, indent=2))
|
|
543
|
+
else:
|
|
544
|
+
print(f"{result['verdict']} (confidence {result['confidence']:.2f})")
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# Enough of a task prompt for the rubrics to judge it. The longest prompt in forge's
|
|
549
|
+
# own test plan is about 3.5KB; this leaves room without letting one enormous task
|
|
550
|
+
# prompt dominate a request that also carries file excerpts and memory.
|
|
551
|
+
PROMPT_CHARS = 8000
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def cmd_score_plan(args) -> int:
|
|
555
|
+
"""Rate every task in a plan. Advisory output only: nothing ever writes it into tasks.tsv.
|
|
556
|
+
|
|
557
|
+
Writing a Jev tier into the plan (`--jev-act`, gated on a per-repo calibration) was
|
|
558
|
+
removed on 2026-10-17 without ever having acted: no repo reached the calibration bar,
|
|
559
|
+
and model choice is now measured from `plan --explore` draws (`forge dataset routing`).
|
|
560
|
+
"""
|
|
561
|
+
plan, repo = Path(args.plan), Path(args.repo)
|
|
562
|
+
if not plan.is_dir():
|
|
563
|
+
print(f'forge jev score-plan: --plan {args.plan!r} is not a directory', file=sys.stderr)
|
|
564
|
+
return 2
|
|
565
|
+
if not repo.is_dir():
|
|
566
|
+
print(f'forge jev score-plan: --repo {args.repo!r} is not a directory', file=sys.stderr)
|
|
567
|
+
return 2
|
|
568
|
+
tasks = plan / 'tasks.tsv'
|
|
569
|
+
if not tasks.is_file():
|
|
570
|
+
print(f'forge jev score-plan: no tasks.tsv in {args.plan!r}', file=sys.stderr)
|
|
571
|
+
return 2
|
|
572
|
+
config = load_config()
|
|
573
|
+
if not site_ready('jev-score-plan', config):
|
|
574
|
+
return 3
|
|
575
|
+
|
|
576
|
+
from . import gates, routing
|
|
577
|
+
|
|
578
|
+
goal = ''
|
|
579
|
+
goal_file = plan / 'goal.txt'
|
|
580
|
+
if goal_file.is_file():
|
|
581
|
+
goal = goal_file.read_text(errors='replace')[:2000]
|
|
582
|
+
|
|
583
|
+
rows, judgments = [], []
|
|
584
|
+
for line in tasks.read_text(errors='replace').splitlines():
|
|
585
|
+
if not line.strip() or line.startswith('#'):
|
|
586
|
+
continue
|
|
587
|
+
parts = line.split('\t')
|
|
588
|
+
if len(parts) < 7:
|
|
589
|
+
continue
|
|
590
|
+
rows.append(parts)
|
|
591
|
+
|
|
592
|
+
# The whole plan, once, for every task's state. independently_verifiable asks
|
|
593
|
+
# whether confirming a task needs another task in the plan to land first; it had
|
|
594
|
+
# never been shown another task, so it was guessing whether one existed.
|
|
595
|
+
siblings = [dict(id=p[0], deps=p[1], files=p[3], title=p[6]) for p in rows]
|
|
596
|
+
|
|
597
|
+
for parts in rows:
|
|
598
|
+
task_id, task_deps, declared, files, _dwarf, _qa, title = parts[:7]
|
|
599
|
+
approach = ''
|
|
600
|
+
approach_file = plan / 'tasks' / task_id / 'approach.md'
|
|
601
|
+
if approach_file.is_file():
|
|
602
|
+
approach = approach_file.read_text(errors='replace')[:4000]
|
|
603
|
+
# The requirements themselves. decompose.md makes this file mandatory and
|
|
604
|
+
# SKILL.md says a short title is insufficient for QA -- so scoring a task
|
|
605
|
+
# without it was scoring the one thing both documents say is not the task.
|
|
606
|
+
prompt_text = ''
|
|
607
|
+
prompt_file = plan / 'tasks' / task_id / 'prompt.md'
|
|
608
|
+
if prompt_file.is_file():
|
|
609
|
+
prompt_text = prompt_file.read_text(errors='replace')[:PROMPT_CHARS]
|
|
610
|
+
judgment = routing.score_task(repo, goal=goal, task_id=task_id, title=title,
|
|
611
|
+
files=files, approach=approach, prompt=prompt_text,
|
|
612
|
+
declared_difficulty=declared, deps=task_deps,
|
|
613
|
+
siblings=siblings, run_dir=str(plan),
|
|
614
|
+
config=config)
|
|
615
|
+
if judgment is None:
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
# A second request per task, and the only one Phase 4 adds -- the adequacy and
|
|
619
|
+
# verifiability gates ride inside the routing request above. This one cannot,
|
|
620
|
+
# because it asks about a different universe (every tracked file) rather than
|
|
621
|
+
# about the task itself.
|
|
622
|
+
if enabled('gates', config=config):
|
|
623
|
+
predicted = gates.predict_drift(repo, goal=goal, task_id=task_id, title=title,
|
|
624
|
+
declared=files, approach=approach,
|
|
625
|
+
prompt=prompt_text,
|
|
626
|
+
run_dir=str(plan), config=config)
|
|
627
|
+
if predicted:
|
|
628
|
+
judgment['predicted_drift'] = [dict(file=name, probability=probability)
|
|
629
|
+
for name, probability in predicted]
|
|
630
|
+
judgments.append(judgment)
|
|
631
|
+
out = plan / 'tasks' / task_id
|
|
632
|
+
try:
|
|
633
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
634
|
+
(out / 'jev.json').write_text(json.dumps(judgment, indent=2, sort_keys=True) + '\n')
|
|
635
|
+
except OSError:
|
|
636
|
+
pass
|
|
637
|
+
|
|
638
|
+
if not judgments:
|
|
639
|
+
return 3
|
|
640
|
+
|
|
641
|
+
# Tell the repo where its own run data lives: forge keeps plan dirs wherever the user put
|
|
642
|
+
# them, and `forge dataset import --registry` (and forge-ui) find them through this list.
|
|
643
|
+
# A registry rather than moving the file: plan dirs are the user's to place, and
|
|
644
|
+
# relocating them would orphan every run already on disk.
|
|
645
|
+
register_run_dir(repo, plan)
|
|
646
|
+
|
|
647
|
+
if args.json:
|
|
648
|
+
print(json.dumps(judgments, indent=2, sort_keys=True))
|
|
649
|
+
else:
|
|
650
|
+
for judgment in judgments:
|
|
651
|
+
# Two extra columns for the Phase 4 gates. '-' rather than an empty field so
|
|
652
|
+
# a `read` in bash cannot silently shift the columns after it.
|
|
653
|
+
warnings = ','.join(sorted(judgment.get('warnings') or {})) or '-'
|
|
654
|
+
drift = ','.join(item['file'] for item in judgment.get('predicted_drift') or ()) or '-'
|
|
655
|
+
print('\t'.join((judgment['id'], judgment['tier'],
|
|
656
|
+
str(judgment['confidence']), str(judgment['composite']),
|
|
657
|
+
judgment['escalated'] or '-',
|
|
658
|
+
judgment['declared'], warnings, drift)))
|
|
659
|
+
return 0
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def cmd_coupling(args) -> int:
|
|
663
|
+
"""Warn about same-wave task pairs that may conflict despite disjoint files.
|
|
664
|
+
|
|
665
|
+
Advisory only -- like cmd_score_plan, this never edits tasks.tsv or waves.tsv. It
|
|
666
|
+
reads both (waves.tsv must already exist; forge-parallel.sh runs this after
|
|
667
|
+
compute_waves) and writes jev-coupling.json for a human or a later run to inspect.
|
|
668
|
+
"""
|
|
669
|
+
plan, repo = Path(args.plan), Path(args.repo)
|
|
670
|
+
if not plan.is_dir():
|
|
671
|
+
print(f'forge jev coupling: --plan {args.plan!r} is not a directory', file=sys.stderr)
|
|
672
|
+
return 2
|
|
673
|
+
if not repo.is_dir():
|
|
674
|
+
print(f'forge jev coupling: --repo {args.repo!r} is not a directory', file=sys.stderr)
|
|
675
|
+
return 2
|
|
676
|
+
waves_file = plan / 'waves.tsv'
|
|
677
|
+
if not waves_file.is_file():
|
|
678
|
+
print(f'forge jev coupling: no waves.tsv in {args.plan!r} -- run compute_waves first', file=sys.stderr)
|
|
679
|
+
return 2
|
|
680
|
+
config = load_config()
|
|
681
|
+
if not site_ready('jev-coupling', config):
|
|
682
|
+
return 3
|
|
683
|
+
|
|
684
|
+
from . import coupling
|
|
685
|
+
|
|
686
|
+
tasks = list(coupling.parse_tasks(plan).values())
|
|
687
|
+
waves = coupling.parse_waves(plan)
|
|
688
|
+
found = coupling.coupled_pairs(repo, tasks=tasks, waves=waves, run_dir=str(plan), config=config)
|
|
689
|
+
# The other half of the same question. `pairs()` only looks inside a wave, so "task A
|
|
690
|
+
# is written against an interface task B builds later" was structurally out of reach
|
|
691
|
+
# -- and that is what shipped: jevscope's `readme` ran in wave 1 documenting the
|
|
692
|
+
# wave-2 CLI and passed its own review, because the thing it was wrong about did not
|
|
693
|
+
# exist yet. Asked here rather than in a separate command so a plan gets both
|
|
694
|
+
# warnings or neither.
|
|
695
|
+
forward = coupling.forward_dependencies(repo, tasks=tasks, waves=waves,
|
|
696
|
+
run_dir=str(plan), config=config)
|
|
697
|
+
if not found and not forward:
|
|
698
|
+
return 3
|
|
699
|
+
|
|
700
|
+
payload = ([dict(a=a, b=b, probability=p, kind='same-wave') for a, b, p in (found or [])]
|
|
701
|
+
+ [dict(a=a, b=b, probability=p, kind='forward') for a, b, p in (forward or [])])
|
|
702
|
+
try:
|
|
703
|
+
(plan / 'jev-coupling.json').write_text(json.dumps(payload, indent=2, sort_keys=True) + '\n')
|
|
704
|
+
except OSError:
|
|
705
|
+
pass
|
|
706
|
+
|
|
707
|
+
if args.json:
|
|
708
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
709
|
+
else:
|
|
710
|
+
# `kind` is the LAST field: forge-parallel.sh reads these with a three-variable
|
|
711
|
+
# `read`, so a new leading column would silently land in the probability.
|
|
712
|
+
for row in payload:
|
|
713
|
+
print(f"{row['a']}\t{row['b']}\t{row['probability']}\t{row['kind']}")
|
|
714
|
+
return 0
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def cmd_review_triage(args) -> int:
|
|
718
|
+
"""Annotate a FAIL. Exit 0 only when the review looks SUSPECT.
|
|
719
|
+
|
|
720
|
+
The exit code carries the whole signal so the caller stays a one-liner, and it is
|
|
721
|
+
deliberately the narrow case that exits 0: anything else -- a sound review, no key,
|
|
722
|
+
no judgment, a crash -- leaves the verdict to speak for itself.
|
|
723
|
+
"""
|
|
724
|
+
config = load_config()
|
|
725
|
+
if not site_ready('jev-review-triage', config):
|
|
726
|
+
return 3
|
|
727
|
+
from . import review
|
|
728
|
+
|
|
729
|
+
result = review.annotate_failure(review_path=args.review, diff_path=args.diff,
|
|
730
|
+
run_dir=args.run_dir, config=config)
|
|
731
|
+
if result is None:
|
|
732
|
+
return 3
|
|
733
|
+
if args.json:
|
|
734
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
735
|
+
elif result['suspect']:
|
|
736
|
+
for reason in result['reasons']:
|
|
737
|
+
print(reason)
|
|
738
|
+
return 0 if result['suspect'] else 1
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def cmd_memory_curate(args) -> int:
|
|
742
|
+
"""Curate one learning line. Prints `category<TAB>injectable<TAB>matched_key`.
|
|
743
|
+
|
|
744
|
+
A tab-separated line rather than JSON because forge-memory.sh calls this once per
|
|
745
|
+
learning and parsing JSON in bash for three fields is not worth the subprocess. Exit
|
|
746
|
+
3 means "no opinion", and the caller keeps exactly what the model said.
|
|
747
|
+
"""
|
|
748
|
+
config = load_config()
|
|
749
|
+
if not site_ready('jev-memory-curate', config):
|
|
750
|
+
return 3
|
|
751
|
+
from . import memory as memory_module
|
|
752
|
+
|
|
753
|
+
existing = []
|
|
754
|
+
path = Path(args.repo) / '.forge' / 'memory.md'
|
|
755
|
+
if path.is_file():
|
|
756
|
+
try:
|
|
757
|
+
existing = [line.strip(' -') for line in path.read_text(errors='replace').splitlines()
|
|
758
|
+
if line.strip().startswith('-')]
|
|
759
|
+
except OSError:
|
|
760
|
+
existing = []
|
|
761
|
+
|
|
762
|
+
result = memory_module.curate(category=args.category, text=args.text,
|
|
763
|
+
existing=existing, run_dir=args.run_dir, config=config)
|
|
764
|
+
if result is None:
|
|
765
|
+
return 3
|
|
766
|
+
if args.json:
|
|
767
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
768
|
+
else:
|
|
769
|
+
print('\t'.join((result['category'], '1' if result['injectable'] else '0',
|
|
770
|
+
result['matched'] or '')))
|
|
771
|
+
return 0
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def cmd_memory_slice(args) -> int:
|
|
775
|
+
"""Print the memory lines worth injecting for this task, one per line.
|
|
776
|
+
|
|
777
|
+
Exit 3 means "no opinion", and the caller injects exactly what it would have before.
|
|
778
|
+
That is the only safe default: this runs on every dispatch of every run, so a wrong
|
|
779
|
+
answer here is paid for more often than anywhere else in the integration.
|
|
780
|
+
"""
|
|
781
|
+
config = load_config()
|
|
782
|
+
if not site_ready('jev-memory-slice', config):
|
|
783
|
+
return 3
|
|
784
|
+
from . import memory as memory_module
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
task = Path(args.task).read_text(errors='replace')[:4000]
|
|
788
|
+
lines = Path(args.lines).read_text(errors='replace').splitlines()
|
|
789
|
+
except OSError:
|
|
790
|
+
return 3
|
|
791
|
+
kept = memory_module.relevant_lines(
|
|
792
|
+
task=task, lines=lines, run_dir=args.run_dir,
|
|
793
|
+
keep=args.keep if args.keep and args.keep > 0 else memory_module.SLICE_KEEP,
|
|
794
|
+
config=config)
|
|
795
|
+
if kept is None:
|
|
796
|
+
return 3
|
|
797
|
+
for line in kept:
|
|
798
|
+
print(line)
|
|
799
|
+
return 0
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def cmd_memory_stale(args) -> int:
|
|
803
|
+
"""Exit 0 when the fact looks stale, 1 when it still holds, 3 when unknown."""
|
|
804
|
+
config = load_config()
|
|
805
|
+
if not site_ready('jev-memory-stale', config):
|
|
806
|
+
return 3
|
|
807
|
+
from . import memory as memory_module
|
|
808
|
+
|
|
809
|
+
result = memory_module.still_true(text=args.text, anchor_path=args.anchor,
|
|
810
|
+
run_dir=args.run_dir, config=config)
|
|
811
|
+
if result is None:
|
|
812
|
+
return 3
|
|
813
|
+
return 0 if result['stale'] else 1
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_test_subset(args) -> int:
|
|
817
|
+
"""Run the likely-affected tests in a throwaway export of the reviewed commit.
|
|
818
|
+
|
|
819
|
+
Exit 0 only when the subset RAN AND FAILED -- that is the one outcome worth telling
|
|
820
|
+
a reviewer about. Exit 1 covers "ran and passed", which proves nothing here because
|
|
821
|
+
the subset is not the suite, and exit 3 covers everything else. The task worktree is
|
|
822
|
+
never touched, so no fingerprint can change.
|
|
823
|
+
"""
|
|
824
|
+
import subprocess
|
|
825
|
+
import tempfile
|
|
826
|
+
|
|
827
|
+
receipt_values: dict = {}
|
|
828
|
+
|
|
829
|
+
def receipt(outcome: str, **values) -> None:
|
|
830
|
+
"""Record how the subset run ended, every outcome, not just a failure. A selection
|
|
831
|
+
is only trainable when we know whether the chosen tests passed, failed on this
|
|
832
|
+
diff, or could not run. Fail-open."""
|
|
833
|
+
receipt_values.update(values)
|
|
834
|
+
try:
|
|
835
|
+
scripts = str(Path(__file__).resolve().parent.parent)
|
|
836
|
+
if scripts not in sys.path:
|
|
837
|
+
sys.path.insert(0, scripts)
|
|
838
|
+
from forge_dataset.capture import point
|
|
839
|
+
texts = {k: receipt_values.pop(k) for k in ('candidate_output', 'base_output') if k in receipt_values}
|
|
840
|
+
point('subset_run', args.run_dir, None, dict(receipt_values, outcome=outcome), texts=texts)
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
|
|
844
|
+
# Usage errors come first and need neither a key nor a config: a caller who gets
|
|
845
|
+
# exit 3 ("Jev is unavailable") for a malformed command would conclude the wrong
|
|
846
|
+
# thing and never fix the command.
|
|
847
|
+
if '{files}' not in args.subset_command:
|
|
848
|
+
# Without the placeholder there is nowhere to put the selection, and appending
|
|
849
|
+
# blindly is what broke this before. Do nothing rather than run the full suite
|
|
850
|
+
# twice under a name that says "subset".
|
|
851
|
+
return 2
|
|
852
|
+
repo = Path(args.repo)
|
|
853
|
+
if not repo.is_dir():
|
|
854
|
+
return 2
|
|
855
|
+
config = load_config()
|
|
856
|
+
if not site_ready('jev-test-subset', config):
|
|
857
|
+
return 3
|
|
858
|
+
from . import subset
|
|
859
|
+
|
|
860
|
+
try:
|
|
861
|
+
task = Path(args.task).read_text(errors='replace')[:6000]
|
|
862
|
+
except OSError:
|
|
863
|
+
return 3
|
|
864
|
+
changed = []
|
|
865
|
+
if args.changed:
|
|
866
|
+
try:
|
|
867
|
+
changed = Path(args.changed).read_text(errors='replace').split()[:200]
|
|
868
|
+
except OSError:
|
|
869
|
+
changed = []
|
|
870
|
+
|
|
871
|
+
candidates = subset.candidate_tests(repo)
|
|
872
|
+
if not candidates:
|
|
873
|
+
return 3
|
|
874
|
+
scored = subset.select(repo, task=task, changed=changed, candidates=candidates,
|
|
875
|
+
run_dir=args.run_dir, config=config)
|
|
876
|
+
if not scored:
|
|
877
|
+
return 3
|
|
878
|
+
|
|
879
|
+
# No measured per-repo threshold exists, so rather than invent one this takes the
|
|
880
|
+
# highest-scoring few. Over-selecting only costs runtime in a throwaway directory;
|
|
881
|
+
# the result is advisory either way and never decides verification.
|
|
882
|
+
chosen = [name for _p, name in scored[:max(1, args.max)]]
|
|
883
|
+
receipt_values.update(chosen=chosen, scored=[[p, name] for p, name in scored[:40]], changed=changed,
|
|
884
|
+
commit=args.commit, base=args.base)
|
|
885
|
+
export_dir = tempfile.mkdtemp(prefix='forge-subset-')
|
|
886
|
+
try:
|
|
887
|
+
if not subset.export(repo, args.commit, export_dir):
|
|
888
|
+
receipt('export_failed')
|
|
889
|
+
return 3
|
|
890
|
+
# The template says how THIS project's runner takes a file list, because there
|
|
891
|
+
# is no general answer. `pytest -q <files>` works; `npm test <files>` does not
|
|
892
|
+
# (it needs `--`), `go test ./...` and `bash tests/check.sh` take no file list at
|
|
893
|
+
# all, and `python -m unittest discover -s tests` rejects paths outright. Guessing
|
|
894
|
+
# produced a command that failed for a reason having nothing to do with the diff.
|
|
895
|
+
command = args.subset_command.replace('{files}', ' '.join(chosen))
|
|
896
|
+
receipt_values['command'] = command
|
|
897
|
+
started = time.time()
|
|
898
|
+
try:
|
|
899
|
+
completed = subprocess.run(command, shell=True, cwd=export_dir,
|
|
900
|
+
capture_output=True, text=True,
|
|
901
|
+
timeout=max(1, args.timeout))
|
|
902
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
903
|
+
receipt('timeout' if isinstance(error, subprocess.TimeoutExpired) else 'candidate_error',
|
|
904
|
+
candidate_s=round(time.time() - started, 3))
|
|
905
|
+
return 3
|
|
906
|
+
tail = (completed.stdout or '') + (completed.stderr or '')
|
|
907
|
+
receipt_values.update(candidate_exit=completed.returncode, candidate_s=round(time.time() - started, 3),
|
|
908
|
+
candidate_output=tail[-65536:])
|
|
909
|
+
if completed.returncode == 0:
|
|
910
|
+
receipt('pass')
|
|
911
|
+
return 1
|
|
912
|
+
finally:
|
|
913
|
+
subset.cleanup(export_dir)
|
|
914
|
+
|
|
915
|
+
# It failed. That is not yet evidence against the diff. A throwaway export has no
|
|
916
|
+
# installed dependencies of its own, so `No module named pytest` looks exactly like
|
|
917
|
+
# a broken test -- and reporting an environment failure to a reviewer as "your diff
|
|
918
|
+
# breaks tests" is worse than saying nothing at all. Pre-existing failures have the
|
|
919
|
+
# same shape. So run the identical subset against the BASE commit and only speak up
|
|
920
|
+
# when the base passes: that is the one case where this diff is implicated.
|
|
921
|
+
if not args.base:
|
|
922
|
+
receipt('fail_no_base')
|
|
923
|
+
return 3
|
|
924
|
+
control_dir = tempfile.mkdtemp(prefix='forge-subset-base-')
|
|
925
|
+
try:
|
|
926
|
+
if not subset.export(repo, args.base, control_dir):
|
|
927
|
+
receipt('base_export_failed')
|
|
928
|
+
return 3
|
|
929
|
+
started = time.time()
|
|
930
|
+
try:
|
|
931
|
+
control = subprocess.run(command, shell=True, cwd=control_dir,
|
|
932
|
+
capture_output=True, text=True,
|
|
933
|
+
timeout=max(1, args.timeout))
|
|
934
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
935
|
+
receipt('base_timeout' if isinstance(error, subprocess.TimeoutExpired) else 'base_error',
|
|
936
|
+
base_s=round(time.time() - started, 3))
|
|
937
|
+
return 3
|
|
938
|
+
receipt_values.update(base_exit=control.returncode, base_s=round(time.time() - started, 3),
|
|
939
|
+
base_output=((control.stdout or '') + (control.stderr or ''))[-65536:])
|
|
940
|
+
if control.returncode != 0:
|
|
941
|
+
# Same failure without the diff: environment or pre-existing, not this change.
|
|
942
|
+
receipt('environment')
|
|
943
|
+
return 3
|
|
944
|
+
finally:
|
|
945
|
+
subset.cleanup(control_dir)
|
|
946
|
+
receipt('regression')
|
|
947
|
+
|
|
948
|
+
print('The likely-affected tests were run against this diff and FAILED.')
|
|
949
|
+
print('The same tests PASS on the commit this task started from, so the failure')
|
|
950
|
+
print('is attributable to this diff rather than to the environment.')
|
|
951
|
+
print('Command: ' + command)
|
|
952
|
+
print(redact(tail[-3000:]))
|
|
953
|
+
return 0
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def main(argv: list[str] | None = None) -> int:
|
|
957
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
958
|
+
try:
|
|
959
|
+
args = parser().parse_args(argv)
|
|
960
|
+
return dict(setup=cmd_setup, sites=cmd_sites, enable=cmd_enable, disable=cmd_disable,
|
|
961
|
+
**{'first-pass': cmd_first_pass, 'retry-pass': cmd_retry_pass},
|
|
962
|
+
status=cmd_status, doctor=cmd_doctor, backtest=cmd_backtest,
|
|
963
|
+
**{'score-plan': cmd_score_plan,
|
|
964
|
+
'coupling': cmd_coupling,
|
|
965
|
+
'review-triage': cmd_review_triage,
|
|
966
|
+
'test-subset': cmd_test_subset,
|
|
967
|
+
'memory-curate': cmd_memory_curate,
|
|
968
|
+
'memory-slice': cmd_memory_slice,
|
|
969
|
+
'memory-stale': cmd_memory_stale,
|
|
970
|
+
'verify-discover': cmd_verify_discover,
|
|
971
|
+
'verify-triage': cmd_verify_triage})[args.command](args)
|
|
972
|
+
except SystemExit as exit:
|
|
973
|
+
# argparse exits the process on a usage error; return the code instead so the
|
|
974
|
+
# shim owns exiting and main() stays callable from a test.
|
|
975
|
+
return int(exit.code or 0)
|
|
976
|
+
except KeyboardInterrupt:
|
|
977
|
+
return 130
|
|
978
|
+
except (OSError, ValueError, RuntimeError, KeyError) as error:
|
|
979
|
+
print('forge jev: ' + redact(str(error)), file=sys.stderr)
|
|
980
|
+
return 3
|