@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,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