@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,435 @@
1
+ #!/usr/bin/env python3
2
+ """forge ui open|status|stop: one shared, detached forge-ui server for every run.
3
+
4
+ `open` starts the server when none is running (or replaces one that runs old code) and
5
+ prints a link to the run; every later `open` reuses it. State lives in
6
+ `${XDG_STATE_HOME:-~/.local/state}/forge/ui`: `server.json` (what we started), `server.log`
7
+ and `lock` (serialises launchers). The server exits by itself after FORGE_UI_IDLE_S idle seconds.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import errno
13
+ import fcntl
14
+ import http.client
15
+ import json
16
+ import os
17
+ import re
18
+ import signal
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import webbrowser
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ SKILL = Path(__file__).resolve().parent.parent
29
+ # `-I` keeps the script's own directory off sys.path, so both are added by hand.
30
+ sys.path.insert(0, str(SKILL / 'scripts'))
31
+ sys.path.insert(0, str(SKILL / 'ui'))
32
+
33
+ HOST = '127.0.0.1'
34
+ LISTENING = re.compile(r'^forge-ui on http://127\.0\.0\.1:(\d+)/', re.M)
35
+ DEFAULT_PORT = 8765
36
+ DEFAULT_IDLE_S = 14400
37
+ LOCK_WAIT_S = 10
38
+ START_WAIT_S = 5
39
+ STOP_WAIT_S = 2
40
+ BROWSER_WAIT_S = 2 # a browser command that hangs must not hold `open` past its budget
41
+
42
+
43
+ def state_dir() -> Path:
44
+ base = os.environ.get('XDG_STATE_HOME') or str(Path.home() / '.local/state')
45
+ return Path(base).expanduser() / 'forge/ui'
46
+
47
+
48
+ def _say(text):
49
+ print(f'forge-ui: {text}', file=sys.stderr)
50
+
51
+
52
+ def _env_int(name, default):
53
+ raw = os.environ.get(name, '')
54
+ if not raw.strip():
55
+ return default
56
+ try:
57
+ value = int(raw)
58
+ except ValueError:
59
+ value = -1
60
+ if value < 0:
61
+ raise ValueError(f'{name}={raw!r} is not a non-negative integer')
62
+ return value
63
+
64
+
65
+ def pid_alive(pid) -> bool:
66
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
67
+ return False
68
+ try:
69
+ os.kill(pid, 0)
70
+ except (ProcessLookupError, OverflowError): # OverflowError: no process can have that pid
71
+ return False
72
+ except PermissionError:
73
+ return True
74
+ return True
75
+
76
+
77
+ def wait_dead(pid, seconds) -> bool:
78
+ deadline = time.monotonic() + seconds
79
+ while pid_alive(pid):
80
+ if time.monotonic() >= deadline:
81
+ return False
82
+ time.sleep(0.05)
83
+ return True
84
+
85
+
86
+ def health(port):
87
+ """The /api/health answer of whatever listens on 127.0.0.1:port, or None."""
88
+ if not isinstance(port, int) or isinstance(port, bool) or not 0 < port < 65536:
89
+ return None
90
+ conn = http.client.HTTPConnection(HOST, port, timeout=1) # direct: no proxy handling
91
+ try:
92
+ conn.request('GET', '/api/health')
93
+ response = conn.getresponse()
94
+ if response.status != 200:
95
+ return None
96
+ data = json.loads(response.read(65536))
97
+ except (OSError, ValueError, RecursionError, http.client.HTTPException):
98
+ return None
99
+ finally:
100
+ conn.close()
101
+ return data if isinstance(data, dict) else None
102
+
103
+
104
+ def open_regular(path, flags):
105
+ """An fd on path, which must be a regular file; OSError otherwise.
106
+
107
+ O_NONBLOCK: a FIFO or device planted at one of our paths must fail, never block.
108
+ """
109
+ fd = os.open(path, flags | os.O_NONBLOCK | os.O_CLOEXEC, 0o644)
110
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
111
+ os.close(fd)
112
+ raise OSError(errno.EINVAL, 'not a regular file', str(path))
113
+ return fd
114
+
115
+
116
+ def read_state(state):
117
+ """server.json as a dict, or None when it is missing, unreadable or not a JSON object."""
118
+ try:
119
+ with os.fdopen(open_regular(state / 'server.json', os.O_RDONLY), 'rb') as handle:
120
+ data = json.loads(handle.read())
121
+ except (OSError, ValueError, RecursionError): # ValueError covers bad UTF-8 and bad JSON
122
+ return None
123
+ return data if isinstance(data, dict) else None
124
+
125
+
126
+ def write_state(state, data):
127
+ handle, temp = tempfile.mkstemp(prefix='server.json.', dir=str(state))
128
+ try:
129
+ with os.fdopen(handle, 'w') as out:
130
+ json.dump(data, out, indent=1, sort_keys=True)
131
+ out.write('\n')
132
+ os.replace(temp, state / 'server.json')
133
+ except BaseException:
134
+ try:
135
+ os.unlink(temp)
136
+ except OSError:
137
+ pass
138
+ raise
139
+
140
+
141
+ def confirmed(recorded):
142
+ """The health answer if the recorded server is alive and is a forge-ui with that pid."""
143
+ if not recorded or not pid_alive(recorded.get('pid')):
144
+ return None
145
+ answer = health(recorded.get('port'))
146
+ if answer and answer.get('app') == 'forge-ui' and answer.get('pid') == recorded['pid']:
147
+ return answer
148
+ return None
149
+
150
+
151
+ class Lock:
152
+ """An flock on <state>/lock, so concurrent launchers see one server."""
153
+
154
+ def __init__(self, state):
155
+ self.path = state / 'lock'
156
+ self.handle = None
157
+
158
+ def __enter__(self):
159
+ self.handle = open_regular(self.path, os.O_WRONLY | os.O_CREAT)
160
+ deadline = time.monotonic() + LOCK_WAIT_S
161
+ while True:
162
+ try:
163
+ fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
164
+ return self
165
+ except OSError as exc:
166
+ busy = exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN)
167
+ if busy and time.monotonic() < deadline:
168
+ time.sleep(0.05)
169
+ continue
170
+ os.close(self.handle)
171
+ if busy:
172
+ raise TimeoutError(errno.ETIMEDOUT, 'another forge ui is holding it', str(self.path)) from None
173
+ raise OSError(exc.errno, exc.strerror, str(self.path)) from None # flock names no file
174
+
175
+ def __exit__(self, *exc):
176
+ os.close(self.handle) # closing releases the lock
177
+
178
+
179
+ def terminate(pid) -> bool:
180
+ """SIGTERM a pid that health has confirmed; True once it is gone."""
181
+ try:
182
+ os.kill(pid, signal.SIGTERM)
183
+ except ProcessLookupError:
184
+ return True
185
+ except PermissionError:
186
+ return False
187
+ return wait_dead(pid, STOP_WAIT_S)
188
+
189
+
190
+ def _spawn(state, port, registry, idle):
191
+ """Start the server; returns (process, port) or (None, error text)."""
192
+ log = state / 'server.log'
193
+ env = {k: v for k, v in os.environ.items() if not k.startswith('FORGE_')}
194
+ command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--port', str(port),
195
+ '--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
196
+ with os.fdopen(open_regular(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC), 'w') as out:
197
+ proc = subprocess.Popen(command, cwd=str(state), stdin=subprocess.DEVNULL, stdout=out, stderr=out,
198
+ start_new_session=True, close_fds=True, env=env)
199
+ deadline = time.monotonic() + START_WAIT_S
200
+ while time.monotonic() < deadline:
201
+ try:
202
+ match = LISTENING.search(log.read_text(errors='replace'))
203
+ except OSError:
204
+ match = None
205
+ if match:
206
+ return proc, int(match.group(1))
207
+ if proc.poll() is not None:
208
+ return None, proc.returncode
209
+ time.sleep(0.05)
210
+ proc.terminate()
211
+ return None, None
212
+
213
+
214
+ def start_server(state, port, registry, idle):
215
+ """Spawn a server, falling back once to a free port when the preferred one is taken."""
216
+ proc, result = _spawn(state, port, registry, idle)
217
+ if proc is None and result == 2 and port != 0:
218
+ proc, result = _spawn(state, 0, registry, idle)
219
+ if proc is None:
220
+ return None
221
+ return {'pid': proc.pid, 'port': result}
222
+
223
+
224
+ def gui_available() -> bool:
225
+ if sys.platform == 'darwin':
226
+ return True
227
+ return any(os.environ.get(name) for name in ('DISPLAY', 'WAYLAND_DISPLAY', 'BROWSER'))
228
+
229
+
230
+ def browser_wanted(args) -> bool:
231
+ if args.no_browser or os.environ.get('FORGE_UI_BROWSER', '').strip().lower() in ('off', '0', 'false', 'no'):
232
+ return False
233
+ return gui_available()
234
+
235
+
236
+ def launch_browser(url):
237
+ """Open url from a detached helper, waiting at most BROWSER_WAIT_S for it.
238
+
239
+ The helper's output goes to /dev/null: a browser command that blocks must not keep the
240
+ pipes of whoever captures our output open after we return.
241
+ """
242
+ command = [sys.executable, '-I', str(Path(__file__).resolve()), '_browse', url]
243
+ try:
244
+ proc = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
245
+ stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
246
+ proc.wait(BROWSER_WAIT_S)
247
+ except subprocess.TimeoutExpired:
248
+ pass # still starting its browser: leave it
249
+ except OSError: # a missing browser must not fail the run
250
+ pass
251
+
252
+
253
+ def browse(url):
254
+ try:
255
+ webbrowser.open(url)
256
+ except Exception:
257
+ return 1
258
+ return 0
259
+
260
+
261
+ def run_info(directory):
262
+ """(repo, None) for a run dir, or (None, why not). Never raises and never blocks."""
263
+ marker = directory / 'repo'
264
+ try:
265
+ plan = (directory / 'tasks.tsv').is_file()
266
+ solo = (directory / 'prompt.md').is_file() and marker.is_file()
267
+ if not (plan or solo):
268
+ return None, 'not a forge run (no tasks.tsv, and no prompt.md with repo)'
269
+ # a FIFO or a device named `repo` would block the read: only a regular file is read
270
+ if not stat.S_ISREG(os.stat(marker).st_mode):
271
+ return None, f'{marker} is not a regular file'
272
+ repo = marker.read_text().strip()
273
+ except (OSError, ValueError) as exc: # ValueError: not decodable text
274
+ return None, f'cannot read {marker}: {getattr(exc, "strerror", None) or exc}'
275
+ if not repo:
276
+ return None, f'{marker} is empty'
277
+ if '\x00' in repo: # readable text, but no path can hold it
278
+ return None, f'{marker} does not hold a path (it contains a NUL byte)'
279
+ return repo, None
280
+
281
+
282
+ def cmd_open(args):
283
+ import forge_registry
284
+ from forge_ui import __version__, build_id
285
+ from forge_ui.reader import run_key
286
+
287
+ try:
288
+ port = args.port if args.port is not None else _env_int('FORGE_UI_PORT', DEFAULT_PORT)
289
+ idle = _env_int('FORGE_UI_IDLE_S', DEFAULT_IDLE_S)
290
+ except ValueError as exc:
291
+ _say(f'error: {exc}')
292
+ return 2
293
+ if not 0 <= port <= 65535:
294
+ _say(f'error: port {port} is out of range (0-65535)')
295
+ return 2
296
+
297
+ key = None
298
+ if args.dir:
299
+ directory = Path(os.path.realpath(args.dir))
300
+ repo, why = run_info(directory)
301
+ if why:
302
+ _say(f'error: {directory}: {why}')
303
+ return 3
304
+ forge_registry.register_run_dir(Path(repo), directory)
305
+ key = run_key(directory)
306
+
307
+ state = state_dir()
308
+ log = state / 'server.log'
309
+ registry = str(forge_registry.registry_dir())
310
+ spawned = None
311
+ # Any OSError below is state we cannot use (state dir, lock, server.log, server.json) or a
312
+ # spawn that failed: exit 4. An unreadable or invalid server.json is not one: it means no server.
313
+ try:
314
+ build = build_id()
315
+ state.mkdir(parents=True, exist_ok=True)
316
+ with Lock(state):
317
+ server = read_state(state)
318
+ answer = confirmed(server) if server else None
319
+ if answer:
320
+ # the record and the live answer must both name this code
321
+ if server.get('build') == build and answer.get('build') == build and server.get('registry') == registry:
322
+ pass # reuse
323
+ else:
324
+ terminate(server['pid'])
325
+ server = None
326
+ else:
327
+ server = None # dead, or a pid that is not our forge-ui: never signal it
328
+ if server is None:
329
+ spawned = start_server(state, port, registry, idle)
330
+ if spawned is None:
331
+ _say(f'error: the server did not start; see {log}')
332
+ return 4
333
+ server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log),
334
+ started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
335
+ opened = server.get('opened')
336
+ opened = opened if isinstance(opened, list) else []
337
+ mark = key or ''
338
+ # a key counts as opened only when a browser is really launched for it
339
+ first = mark not in opened and browser_wanted(args)
340
+ if first:
341
+ opened.append(mark)
342
+ server['opened'] = opened
343
+ write_state(state, server)
344
+ except OSError as exc: # TimeoutError (the lock) included
345
+ if spawned:
346
+ terminate(spawned['pid']) # our own child, but unrecorded: no later command could find it
347
+ hint = f'; see {log}' if os.path.isfile(log) else ''
348
+ _say(f'error: cannot use {exc.filename or state}: {exc.strerror or exc}{hint}')
349
+ return 4
350
+
351
+ url = f'http://{HOST}:{server["port"]}/'
352
+ if key:
353
+ url += f'#/runs/{key}'
354
+ print(f'forge-ui: {url}')
355
+ if first:
356
+ launch_browser(url)
357
+ return 0
358
+
359
+
360
+ def cmd_status(args):
361
+ server = read_state(state_dir())
362
+ answer = confirmed(server)
363
+ if not answer:
364
+ if args.json:
365
+ print(json.dumps({'running': False}))
366
+ else:
367
+ print('forge-ui: not running')
368
+ return 1
369
+ info = {'running': True, 'pid': server['pid'], 'port': server['port'],
370
+ 'url': f'http://{HOST}:{server["port"]}/', 'version': answer.get('version'),
371
+ 'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log')}
372
+ if args.json:
373
+ print(json.dumps(info, sort_keys=True))
374
+ else:
375
+ print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]})')
376
+ return 0
377
+
378
+
379
+ def cmd_stop(args):
380
+ state = state_dir()
381
+ if not os.path.isdir(state): # unlike Path.is_dir, never raises
382
+ print('forge-ui: not running')
383
+ return 0
384
+ try:
385
+ with Lock(state):
386
+ server = read_state(state)
387
+ if server and confirmed(server):
388
+ if terminate(server['pid']):
389
+ print('forge-ui: stopped')
390
+ else:
391
+ _say(f'warning: pid {server["pid"]} did not exit within {STOP_WAIT_S}s')
392
+ return 0
393
+ else:
394
+ print('forge-ui: not running')
395
+ if server and pid_alive(server.get('pid')):
396
+ return 0 # a live pid health could not vouch for: leave it and its record alone
397
+ try:
398
+ (state / 'server.json').unlink()
399
+ except OSError:
400
+ pass
401
+ except OSError as exc: # the lock, TimeoutError included: exit 0 either way
402
+ _say(f'warning: cannot use {exc.filename or state}: {exc.strerror or exc}')
403
+ return 0
404
+
405
+
406
+ def serve(argv):
407
+ from forge_ui.__main__ import main
408
+ return main(argv)
409
+
410
+
411
+ def build_parser():
412
+ parser = argparse.ArgumentParser(prog='forge ui', description='Open runs in the shared forge-ui server.')
413
+ sub = parser.add_subparsers(dest='command', required=True)
414
+ opened = sub.add_parser('open', help='start or reuse the server and open a run')
415
+ opened.add_argument('dir', nargs='?', metavar='DIR', help='a run dir (plan or solo); omit to open the run list')
416
+ opened.add_argument('--no-browser', action='store_true', help='print the link without opening it')
417
+ opened.add_argument('--port', type=int, metavar='N', help='preferred port (default FORGE_UI_PORT or 8765)')
418
+ status = sub.add_parser('status', help='exit 0 when the server is running, 1 when not')
419
+ status.add_argument('--json', action='store_true')
420
+ sub.add_parser('stop', help='stop the server')
421
+ return parser
422
+
423
+
424
+ def main(argv=None):
425
+ argv = list(sys.argv[1:] if argv is None else argv)
426
+ if argv and argv[0] == '_serve':
427
+ return serve(argv[1:])
428
+ if len(argv) == 2 and argv[0] == '_browse':
429
+ return browse(argv[1])
430
+ args = build_parser().parse_args(argv) # a usage error exits 2
431
+ return {'open': cmd_open, 'status': cmd_status, 'stop': cmd_stop}[args.command](args)
432
+
433
+
434
+ if __name__ == '__main__':
435
+ sys.exit(main())
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Update the canonical checkout, then refresh each harness through the installer.
3
+ set -uo pipefail
4
+
5
+ main() {
6
+ local source_dir dry=0 local_only=0 upstream remote branch before after changes
7
+ source_dir="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || return 2
8
+ while [ $# -gt 0 ]; do
9
+ case "$1" in
10
+ --source) source_dir="${2:?--source needs a Forge checkout}"; shift 2 ;;
11
+ --dry-run) dry=1; shift ;;
12
+ --local) local_only=1; shift ;;
13
+ --help|-h)
14
+ echo 'usage: forge update [--source <checkout>] [--local] [--dry-run]'
15
+ echo 'Default: fast-forward the tracked upstream, then refresh harness installations.'
16
+ echo '--local: refresh installations from the current files without fetching.'
17
+ return 0 ;;
18
+ *) echo "forge: unknown update option: $1" >&2; return 2 ;;
19
+ esac
20
+ done
21
+ source_dir="$(cd -P "$source_dir" && pwd)" || return 2
22
+ if [ ! -f "$source_dir/SKILL.md" ] || [ ! -f "$source_dir/scripts/forge-install.sh" ]; then
23
+ echo "forge: not a Forge source directory: $source_dir" >&2; return 2
24
+ fi
25
+ echo "forge: update source: $source_dir"
26
+ if [ "$local_only" = 0 ]; then
27
+ if [ "$(git -C "$source_dir" rev-parse --show-toplevel 2>/dev/null)" != "$source_dir" ]; then
28
+ case "$source_dir" in
29
+ */node_modules/*)
30
+ # @latest, because older npm updated a global package within ^version: never 0.7 -> 0.8.
31
+ echo 'forge: installed with npm; update with: npm install -g @giamme/forge@latest && forge install' >&2; return 2 ;;
32
+ esac
33
+ echo 'forge: update needs the original Git checkout; use --source <checkout> or --local' >&2; return 2
34
+ fi
35
+ changes="$(git -C "$source_dir" status --porcelain --untracked-files=all)" || return $?
36
+ if [ -n "$changes" ]; then
37
+ echo 'forge: checkout has local changes; commit or stash them, or use --local to refresh current files' >&2; return 2
38
+ fi
39
+ branch="$(git -C "$source_dir" symbolic-ref --quiet --short HEAD)" || {
40
+ echo 'forge: detached HEAD; check out a tracking branch or use --local' >&2; return 2;
41
+ }
42
+ upstream="$(git -C "$source_dir" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || {
43
+ echo 'forge: branch has no upstream; configure tracking or use --local' >&2; return 2;
44
+ }
45
+ remote="$(git -C "$source_dir" config --get "branch.$branch.remote")" || return 2
46
+ if [ "$dry" = 1 ]; then
47
+ echo "forge: would fetch $remote and fast-forward $branch to $upstream (no network in dry run)"
48
+ else
49
+ before="$(git -C "$source_dir" rev-parse HEAD)" || return 2
50
+ git -C "$source_dir" fetch -- "$remote" || return $?
51
+ git -C "$source_dir" merge --ff-only -- "$upstream" || {
52
+ echo 'forge: could not fast-forward; resolve branch divergence before updating installations' >&2; return 1;
53
+ }
54
+ after="$(git -C "$source_dir" rev-parse HEAD)" || return 2
55
+ echo "forge: source $before -> $after"
56
+ fi
57
+ fi
58
+ # Updating Forge does not offer installation of optional dependencies.
59
+ if [ "$dry" = 1 ]; then
60
+ FORGE_RIPWIRE=off /bin/bash "$source_dir/scripts/forge-install.sh" --dry-run || return $?
61
+ echo 'forge: preview only; remote changes and live harness availability are not verified'
62
+ else
63
+ FORGE_RIPWIRE=off /bin/bash "$source_dir/scripts/forge-install.sh" || {
64
+ echo 'forge: source retained; some installations failed. Fix the reported cause and retry with --local' >&2; return 1;
65
+ }
66
+ echo 'forge: installations refreshed; restart active harness sessions to load the updated skill'
67
+ fi
68
+ }
69
+
70
+ # Parse the function before fetching: the update may replace this script on disk.
71
+ main "$@"; exit $?