@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,240 @@
1
+ #!/usr/bin/env python3
2
+ """forge version [--check]: which forge this is, and whether a newer release exists.
3
+
4
+ `forge version` prints the checkout's VERSION, commit and tracked upstream, plus the newest
5
+ release seen by the last check. `--check` asks the upstream now.
6
+
7
+ `notice` is what the runners call when a solo run, `plan` or `run` ends: at most once a day it
8
+ lists the upstream's release tags (`git ls-remote`, short timeout) and, when one is newer than
9
+ VERSION, prints a single line saying so. It is silent offline, when the checkout has no
10
+ upstream, in CI (`CI` set) and when `FORGE_UPDATE_CHECK=off`, and it always exits 0: it never
11
+ changes a run's outcome. The last check is cached in
12
+ `${XDG_STATE_HOME:-~/.local/state}/forge/update-check.json`.
13
+
14
+ An npm installation (the package sits under a `node_modules` folder, the same rule as
15
+ forge-update.sh) has no git upstream: there the newest release is the package's `latest` on the
16
+ npm registry (`npm_config_registry` when set, as npm reads it), and the update command is npm's.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+ from pathlib import Path
31
+
32
+ SKILL = Path(__file__).resolve().parent.parent
33
+ DAY_S = 24 * 3600
34
+ TIMEOUT_S = 5
35
+ _TAG = re.compile(r'^refs/tags/v(\d+(?:\.\d+)*)$')
36
+ _OFF = ('off', '0', 'false', 'no')
37
+ NPM_PACKAGE = '@giamme/forge'
38
+ NPM_REGISTRY = 'https://registry.npmjs.org/'
39
+
40
+
41
+ def state_file() -> Path:
42
+ base = os.environ.get('XDG_STATE_HOME', '')
43
+ if not os.path.isabs(base): # unset, empty or relative: the XDG default
44
+ base = str(Path.home() / '.local/state')
45
+ return Path(base) / 'forge' / 'update-check.json'
46
+
47
+
48
+ def _git(source: Path, *args, timeout=TIMEOUT_S):
49
+ env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='', SSH_ASKPASS='',
50
+ GIT_SSH_COMMAND=os.environ.get('GIT_SSH_COMMAND') or 'ssh -o BatchMode=yes')
51
+ try:
52
+ out = subprocess.run(['git', '-C', str(source), *args], capture_output=True, text=True,
53
+ stdin=subprocess.DEVNULL, env=env, timeout=timeout)
54
+ except (OSError, subprocess.SubprocessError):
55
+ return None
56
+ return out.stdout.strip() if out.returncode == 0 else None
57
+
58
+
59
+ def _key(version: str):
60
+ return tuple(int(part) for part in version.split('.'))
61
+
62
+
63
+ def newer(candidate: str | None, current: str | None) -> bool:
64
+ try:
65
+ a, b = _key(candidate), _key(current)
66
+ except (AttributeError, ValueError):
67
+ return False
68
+ width = max(len(a), len(b))
69
+ return a + (0,) * (width - len(a)) > b + (0,) * (width - len(b))
70
+
71
+
72
+ def local_version(source: Path) -> str | None:
73
+ try:
74
+ text = (source / 'VERSION').read_text(encoding='utf-8').strip()
75
+ except (OSError, UnicodeDecodeError):
76
+ return None
77
+ return text if re.fullmatch(r'\d+(?:\.\d+)*', text) else None
78
+
79
+
80
+ def checkout(source: Path) -> dict:
81
+ """The git facts of a forge checkout; empty when it is not one (a copied installation)."""
82
+ top = _git(source, 'rev-parse', '--show-toplevel')
83
+ if not top or Path(top).resolve() != source.resolve():
84
+ return {}
85
+ info = dict(commit=_git(source, 'rev-parse', '--short', 'HEAD'),
86
+ branch=_git(source, 'symbolic-ref', '--quiet', '--short', 'HEAD'))
87
+ remote = _git(source, 'config', '--get', f'branch.{info["branch"]}.remote') if info['branch'] else None
88
+ info['upstream'] = _git(source, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') \
89
+ if info['branch'] else None
90
+ info['remote'] = remote or ('origin' if _git(source, 'config', '--get', 'remote.origin.url') else None)
91
+ info['url'] = _git(source, 'config', '--get', f'remote.{info["remote"]}.url') if info['remote'] else None
92
+ return info
93
+
94
+
95
+ def npm_install(source: Path) -> dict:
96
+ """An npm installation's facts, like checkout()'s: empty when `source` is not under node_modules."""
97
+ if 'node_modules' not in source.resolve().parts:
98
+ return {}
99
+ try:
100
+ name = json.loads((source / 'package.json').read_text(encoding='utf-8')).get('name') or NPM_PACKAGE
101
+ except (OSError, ValueError, UnicodeDecodeError, AttributeError):
102
+ name = NPM_PACKAGE
103
+ registry = os.environ.get('npm_config_registry') or os.environ.get('NPM_CONFIG_REGISTRY') or NPM_REGISTRY
104
+ url = registry.rstrip('/') + '/' + urllib.parse.quote(name, safe='@') + '/latest'
105
+ return dict(npm=name, remote='npm', url=url)
106
+
107
+
108
+ def update_command(info: dict) -> str:
109
+ return f'npm install -g {info["npm"]}@latest && forge install' if info.get('npm') else 'forge update'
110
+
111
+
112
+ def npm_latest(url: str) -> str | None:
113
+ """The `latest` version on the registry, or None when it cannot be read in time."""
114
+ try:
115
+ with urllib.request.urlopen(urllib.request.Request(url, headers={'Accept': 'application/json'}),
116
+ timeout=TIMEOUT_S) as reply:
117
+ version = json.loads(reply.read().decode('utf-8')).get('version')
118
+ except Exception: # offline, HTTP error, a proxy page instead of JSON: no answer, never a failure
119
+ return None
120
+ return version if isinstance(version, str) and re.fullmatch(r'\d+(?:\.\d+)*', version) else None
121
+
122
+
123
+ def latest_release(source: Path, remote: str) -> str | None:
124
+ """The highest vX[.Y[.Z]] tag on the remote, or None when it cannot be reached in time."""
125
+ out = _git(source, 'ls-remote', '--tags', '--refs', '--', remote, 'v*')
126
+ if out is None:
127
+ return None
128
+ versions = [m.group(1) for m in (_TAG.match(line.split('\t')[-1]) for line in out.splitlines()) if m]
129
+ return max(versions, key=_key, default=None)
130
+
131
+
132
+ def read_cache() -> dict:
133
+ try:
134
+ data = json.loads(state_file().read_text(encoding='utf-8'))
135
+ except (OSError, ValueError, UnicodeDecodeError):
136
+ return {}
137
+ return data if isinstance(data, dict) else {}
138
+
139
+
140
+ def write_cache(data: dict) -> None:
141
+ path = state_file()
142
+ try:
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.update-check.')
145
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
146
+ json.dump(data, handle)
147
+ os.replace(tmp, path)
148
+ except OSError:
149
+ pass
150
+
151
+
152
+ def check(source: Path, info: dict) -> dict:
153
+ """Ask the upstream now and cache the answer. Offline keeps the last known release."""
154
+ cache = read_cache()
155
+ found = npm_latest(info['url']) if info.get('npm') else latest_release(source, info['remote'])
156
+ same = cache.get('url') == info['url']
157
+ entry = dict(checked=time.time(), url=info['url'],
158
+ latest=found if found else (cache.get('latest') if same else None), reached=found is not None)
159
+ write_cache(entry)
160
+ return entry
161
+
162
+
163
+ def disabled() -> bool:
164
+ if os.environ.get('FORGE_UPDATE_CHECK', '').strip().lower() in _OFF:
165
+ return True
166
+ ci = os.environ.get('CI', '').strip().lower()
167
+ return bool(ci) and ci not in _OFF
168
+
169
+
170
+ def available_line(latest: str, current: str, info: dict | None = None) -> str:
171
+ return f'forge {latest} is available (you have {current}): {update_command(info or {})}'
172
+
173
+
174
+ def notice(source: Path) -> None:
175
+ if disabled():
176
+ return
177
+ current = local_version(source)
178
+ info = checkout(source) or npm_install(source)
179
+ if not current or not info.get('remote'):
180
+ return
181
+ cache = read_cache()
182
+ checked = cache.get('checked')
183
+ if isinstance(checked, (int, float)) and cache.get('url') == info['url'] and 0 <= time.time() - checked < DAY_S:
184
+ return # checked (and told) within the last day
185
+ entry = check(source, info)
186
+ if newer(entry['latest'], current):
187
+ print(available_line(entry['latest'], current, info))
188
+
189
+
190
+ def show(source: Path, ask: bool) -> int:
191
+ current = local_version(source)
192
+ print(f'forge {current or "unknown"}')
193
+ info = checkout(source) or npm_install(source)
194
+ if not info:
195
+ print(f'checkout: {source} (not a git checkout; update it with forge update --source <checkout>)')
196
+ return 0
197
+ if info.get('npm'):
198
+ print(f'installed: {source} (npm package {info["npm"]})')
199
+ print(f'upstream: npm registry ({info["url"]})')
200
+ else:
201
+ where = info['branch'] or 'detached HEAD'
202
+ print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
203
+ if info.get('remote'):
204
+ print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
205
+ else:
206
+ print('upstream: none')
207
+ entry = check(source, info) if ask and info.get('remote') else read_cache()
208
+ if ask and info.get('remote') and not entry.get('reached'):
209
+ print(f'latest: could not reach {info["remote"]}')
210
+ elif entry.get('latest') and entry.get('url') == info['url']:
211
+ when = time.strftime('%Y-%m-%d %H:%M', time.localtime(entry['checked'])) \
212
+ if isinstance(entry.get('checked'), (int, float)) else 'unknown'
213
+ print(f'latest: {entry["latest"]} (checked {when})')
214
+ if newer(entry['latest'], current):
215
+ print(available_line(entry['latest'], current, info))
216
+ else:
217
+ print('latest: not checked yet; run forge version --check')
218
+ if info.get('npm'):
219
+ print(f'update: {update_command(info)}')
220
+ return 0
221
+
222
+
223
+ def main(argv=None) -> int:
224
+ parser = argparse.ArgumentParser(prog='forge version', description='Show the forge version and check for a newer one.')
225
+ parser.add_argument('--check', action='store_true', help='ask the upstream for its newest release now')
226
+ parser.add_argument('--source', type=Path, default=SKILL, help=argparse.SUPPRESS)
227
+ parser.add_argument('mode', nargs='?', choices=['notice'], help=argparse.SUPPRESS)
228
+ args = parser.parse_args(argv)
229
+ source = args.source.resolve()
230
+ if args.mode == 'notice':
231
+ try:
232
+ notice(source)
233
+ except Exception: # never let the notice change a run's outcome
234
+ pass
235
+ return 0
236
+ return show(source, args.check)
237
+
238
+
239
+ if __name__ == '__main__':
240
+ sys.exit(main())
@@ -0,0 +1,439 @@
1
+ """Forge's run dataset: the inputs and outcomes of every run, kept after run dirs are gone.
2
+
3
+ Run and plan directories hold nearly everything a decision model could learn from --
4
+ prompts, diffs, the resolved model of every dispatch, verdicts, tokens, Jev judgments --
5
+ but they live in /tmp and scratchpads and get deleted. The only durable record,
6
+ <repo>/.forge/ledger.tsv, keeps spec strings and verdicts and none of the inputs. This
7
+ package keeps a global, append-only copy of each run's raw events outside every repo:
8
+
9
+ ${FORGE_DATA_HOME:-${XDG_DATA_HOME:-~/.local/share}/forge}/dataset/
10
+ dataset.sqlite events (rid-keyed, INSERT OR IGNORE) and repos
11
+ blobs/<aa>/<sha>.gz capped, gzipped text, addressed by the sha of the STORED bytes
12
+ errors.log capture failures
13
+
14
+ What it stores is raw state and outcomes, never training examples: an exporter derives
15
+ (state, question, label) rows per decision later, so a question nobody has thought of yet
16
+ can still be answered from old runs.
17
+
18
+ Capture is fail-open in the same way Jev is. Every entry point swallows its own errors
19
+ into errors.log, never prints, never raises, and bounds its wait on the database with the
20
+ SQLite busy timeout. It uses no signals, because Fractal captures from worker threads.
21
+ FORGE_DATASET=off turns it off.
22
+
23
+ Nothing that builds a prompt may ever read this store: a reviewer that could see earlier
24
+ runs' verdicts would no longer be independent.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import gzip
29
+ import hashlib
30
+ import json
31
+ import os
32
+ import sqlite3
33
+ import subprocess
34
+ import time
35
+ import traceback
36
+ import uuid
37
+ from pathlib import Path
38
+
39
+ import forge_registry
40
+
41
+ SCHEMA = 2
42
+ SCRIPTS = Path(__file__).resolve().parent.parent
43
+ SKILL = SCRIPTS.parent
44
+ BUSY_TIMEOUT_S = 1.0
45
+ ERRORS_MAX = 1 << 20
46
+ OFF = ('off', '0', 'no', 'false')
47
+ DRY = False # import --dry-run: build events and blob references, write nothing
48
+
49
+ # (head, tail) bytes kept per kind of content. A cut keeps both ends because the end is
50
+ # often what matters: a QA prompt ends with the diff, a log ends with the failure.
51
+ CAPS = {
52
+ 'prompt': (256 << 10, 768 << 10),
53
+ 'diff': (1536 << 10, 512 << 10),
54
+ 'message': (64 << 10, 192 << 10),
55
+ 'log': (16 << 10, 256 << 10),
56
+ 'text': (64 << 10, 192 << 10),
57
+ 'request': (768 << 10, 256 << 10),
58
+ }
59
+
60
+ SCHEMA_SQL = """
61
+ CREATE TABLE IF NOT EXISTS events (
62
+ rid TEXT PRIMARY KEY,
63
+ kind TEXT NOT NULL,
64
+ ts REAL NOT NULL,
65
+ src TEXT NOT NULL,
66
+ repo TEXT, run TEXT, run_id TEXT, mode TEXT, task TEXT, attempt INTEGER,
67
+ parent TEXT, inv TEXT, forge_version TEXT, registry_sha TEXT,
68
+ data TEXT NOT NULL, code_sha TEXT, forge_rev TEXT
69
+ );
70
+ CREATE INDEX IF NOT EXISTS events_run ON events(run, task);
71
+ CREATE INDEX IF NOT EXISTS events_kind ON events(kind, ts);
72
+ CREATE TABLE IF NOT EXISTS repos (
73
+ repo TEXT PRIMARY KEY, path TEXT, origin TEXT, first_seen REAL, last_seen REAL
74
+ );
75
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
76
+ """
77
+
78
+ EVENT_COLUMNS = ('rid', 'kind', 'ts', 'src', 'repo', 'run', 'run_id', 'mode', 'task',
79
+ 'attempt', 'parent', 'inv', 'forge_version', 'registry_sha', 'data', 'code_sha', 'forge_rev')
80
+ # Columns added after schema 1, in order. connect() adds any a store is missing.
81
+ ADDED_COLUMNS = (('code_sha', 'TEXT'), ('forge_rev', 'TEXT'))
82
+ PROVENANCE = ('forge_version', 'registry_sha', 'code_sha', 'forge_rev')
83
+
84
+
85
+ def enabled() -> bool:
86
+ return os.environ.get('FORGE_DATASET', 'on').strip().lower() not in OFF
87
+
88
+
89
+ def data_home() -> Path:
90
+ explicit = os.environ.get('FORGE_DATA_HOME')
91
+ if explicit:
92
+ return Path(explicit).expanduser()
93
+ base = os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local' / 'share')
94
+ return Path(base).expanduser() / 'forge'
95
+
96
+
97
+ def dataset_dir() -> Path:
98
+ return data_home() / 'dataset'
99
+
100
+
101
+ def config_runs_dir() -> Path:
102
+ """The runs registry (forge_registry.registry_dir): plan dirs per repo."""
103
+ return forge_registry.registry_dir()
104
+
105
+
106
+ def log_error(where: str, error: BaseException | str) -> None:
107
+ """Record a capture failure. Never raises: this is the last line of defence."""
108
+ try:
109
+ directory = dataset_dir()
110
+ directory.mkdir(parents=True, exist_ok=True)
111
+ path = directory / 'errors.log'
112
+ if path.exists() and path.stat().st_size > ERRORS_MAX:
113
+ path.replace(directory / 'errors.log.1')
114
+ if isinstance(error, BaseException):
115
+ detail = ''.join(traceback.format_exception_only(type(error), error)).strip()
116
+ frames = traceback.extract_tb(error.__traceback__)
117
+ if frames:
118
+ last = frames[-1]
119
+ detail += ' (%s:%s)' % (Path(last.filename).name, last.lineno)
120
+ else:
121
+ detail = str(error)
122
+ with path.open('a') as handle:
123
+ handle.write('%s\t%s\t%s\n' % (time.strftime('%Y-%m-%dT%H:%M:%S'), where,
124
+ detail.replace('\n', ' ')[:2000]))
125
+ path.chmod(0o600)
126
+ except Exception:
127
+ pass
128
+
129
+
130
+ def _private_dir(path: Path) -> None:
131
+ path.mkdir(parents=True, exist_ok=True)
132
+ try:
133
+ path.chmod(0o700)
134
+ except OSError:
135
+ pass
136
+
137
+
138
+ def inside(path: Path, root: Path) -> bool:
139
+ try:
140
+ path.resolve().relative_to(root.resolve())
141
+ return True
142
+ except (ValueError, OSError):
143
+ return False
144
+
145
+
146
+ def location_refused(repo: str | Path | None) -> str | None:
147
+ """Why the store must not be written for this repo, or None.
148
+
149
+ A store inside the repo would dirty the user's tree, and `integrate` refuses to run
150
+ on a dirty tree. Inside a forge worktree it would land in a task's diff.
151
+ """
152
+ store = dataset_dir()
153
+ if '.forge-worktrees' in store.resolve().parts:
154
+ return 'the dataset dir is inside a forge worktree'
155
+ if repo and inside(store, Path(repo)):
156
+ return 'the dataset dir is inside the repository %s' % repo
157
+ return None
158
+
159
+
160
+ def connect() -> sqlite3.Connection:
161
+ directory = dataset_dir()
162
+ _private_dir(directory)
163
+ path = directory / 'dataset.sqlite'
164
+ new = not path.exists()
165
+ db = sqlite3.connect(str(path), timeout=BUSY_TIMEOUT_S, isolation_level=None)
166
+ if new:
167
+ try:
168
+ path.chmod(0o600)
169
+ except OSError:
170
+ pass
171
+ # Switching to WAL needs the file to itself, and SQLite does not wait on the busy
172
+ # timeout for it: two first writers to a brand-new store (threads of one plan's
173
+ # predictions) would see "database is locked" and drop an event. Only a store that is
174
+ # not WAL yet asks, and losing that race is harmless -- the winner makes it WAL, and
175
+ # every write below waits on the busy timeout either way.
176
+ try:
177
+ if str(db.execute('PRAGMA journal_mode').fetchone()[0]).lower() != 'wal':
178
+ db.execute('PRAGMA journal_mode=WAL')
179
+ except sqlite3.OperationalError:
180
+ pass
181
+ db.execute('PRAGMA synchronous=NORMAL')
182
+ db.executescript(SCHEMA_SQL)
183
+ db.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', ?)", (str(SCHEMA),))
184
+ migrate(db)
185
+ return db
186
+
187
+
188
+ def _columns(db: sqlite3.Connection) -> set:
189
+ return {row[1] for row in db.execute('PRAGMA table_info(events)')}
190
+
191
+
192
+ def migrate(db: sqlite3.Connection) -> None:
193
+ """Bring a schema-1 store up to date, once, under the write lock.
194
+
195
+ Concurrent first captures after an upgrade all try this: the column set is re-read
196
+ inside BEGIN IMMEDIATE, so exactly one of them alters the table and the others see
197
+ it done. ADD COLUMN never rewrites the table, so the lock is held for milliseconds.
198
+ The schema number moves last, so an interrupted migration is simply redone.
199
+ """
200
+ if all(name in _columns(db) for name, _ in ADDED_COLUMNS):
201
+ return
202
+ db.execute('BEGIN IMMEDIATE')
203
+ try:
204
+ have = _columns(db)
205
+ for name, kind in ADDED_COLUMNS:
206
+ if name not in have:
207
+ db.execute('ALTER TABLE events ADD COLUMN %s %s' % (name, kind))
208
+ db.execute("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema', ?)", (str(SCHEMA),))
209
+ db.execute('COMMIT')
210
+ except BaseException:
211
+ try:
212
+ db.execute('ROLLBACK')
213
+ except sqlite3.Error:
214
+ pass
215
+ raise
216
+
217
+
218
+ def sha256(data: bytes) -> str:
219
+ return hashlib.sha256(data).hexdigest()
220
+
221
+
222
+ def cut(data: bytes, kind: str) -> tuple[bytes, list | None]:
223
+ head, tail = CAPS.get(kind, CAPS['text'])
224
+ if len(data) <= head + tail:
225
+ return data, None
226
+ marker = b'\n[... forge-dataset: %d bytes cut ...]\n' % (len(data) - head - tail)
227
+ kept = data[:head] + marker + (data[-tail:] if tail else b'')
228
+ return kept, [head, len(data) - tail]
229
+
230
+
231
+ def put_blob(data: bytes | str, kind: str = 'text') -> dict:
232
+ """Store content once, capped; return the reference an event carries.
233
+
234
+ The address is the sha of the stored bytes, and the original's sha and size travel
235
+ in the reference: two different originals can share a cut prefix, and the same
236
+ original cut under two caps must not collide.
237
+ """
238
+ if isinstance(data, str):
239
+ data = data.encode('utf-8', 'replace')
240
+ stored, span = cut(data, kind)
241
+ stored_sha = sha256(stored)
242
+ directory = dataset_dir() / 'blobs' / stored_sha[:2]
243
+ path = directory / (stored_sha + '.gz')
244
+ if not DRY and not path.exists():
245
+ _private_dir(dataset_dir() / 'blobs')
246
+ _private_dir(directory)
247
+ temporary = directory / ('.%s.%s' % (stored_sha, uuid.uuid4().hex))
248
+ with gzip.open(temporary, 'wb', compresslevel=6) as handle:
249
+ handle.write(stored)
250
+ temporary.chmod(0o600)
251
+ temporary.replace(path)
252
+ return dict(sha=stored_sha, original_sha=sha256(data) if span else stored_sha,
253
+ n=len(data), kept=len(stored), cut=span)
254
+
255
+
256
+ def put_file(path: str | Path, kind: str = 'text') -> dict | None:
257
+ path = Path(path)
258
+ try:
259
+ if not path.is_file():
260
+ return None
261
+ return put_blob(path.read_bytes(), kind)
262
+ except OSError:
263
+ return None
264
+
265
+
266
+ def read_blob(stored_sha: str) -> bytes:
267
+ with gzip.open(dataset_dir() / 'blobs' / stored_sha[:2] / (stored_sha + '.gz'), 'rb') as handle:
268
+ return handle.read()
269
+
270
+
271
+ _PROVENANCE: dict | None = None
272
+
273
+
274
+ def provenance() -> dict:
275
+ """Which forge produced an event, so prompt, contract and registry drift is visible later.
276
+
277
+ forge_version is the release, which the dev checkout outlives by weeks of edits.
278
+ code_sha is the content of every file that shapes a run (scripts, SKILL.md, the
279
+ references the orchestrator follows, the effective registry), so two runs share it
280
+ only if they ran the same prompts and contracts. forge_rev is the checkout's commit,
281
+ a readable anchor for it; code_sha also covers uncommitted edits on top.
282
+ """
283
+ global _PROVENANCE
284
+ if _PROVENANCE is None:
285
+ version = None
286
+ try:
287
+ version = (SKILL / 'VERSION').read_text().strip() or None
288
+ except OSError:
289
+ pass
290
+ registry = Path(os.environ.get('FORGE_REGISTRY') or str(SKILL / 'registry.tsv'))
291
+ try:
292
+ registry_bytes = registry.read_bytes()
293
+ except OSError:
294
+ registry_bytes = None
295
+ _PROVENANCE = dict(forge_version=version,
296
+ registry_sha=sha256(registry_bytes)[:12] if registry_bytes is not None else None,
297
+ code_sha=code_sha(registry_bytes), forge_rev=forge_rev())
298
+ return _PROVENANCE
299
+
300
+
301
+ CODE_FILES = ('SKILL.md', 'VERSION', 'forge')
302
+ CODE_TREES = ('scripts', 'references')
303
+
304
+
305
+ def code_sha(registry_bytes: bytes | None) -> str | None:
306
+ try:
307
+ digest = hashlib.sha256()
308
+ paths = [SKILL / name for name in CODE_FILES]
309
+ for tree in CODE_TREES:
310
+ paths.extend(p for p in (SKILL / tree).rglob('*') if p.is_file() and '__pycache__' not in p.parts)
311
+ for path in sorted(paths):
312
+ try:
313
+ content = path.read_bytes()
314
+ except OSError:
315
+ continue
316
+ digest.update(b'%s\0%s\n' % (path.relative_to(SKILL).as_posix().encode(), sha256(content).encode()))
317
+ digest.update(b'registry\0%s\n' % (sha256(registry_bytes).encode() if registry_bytes is not None else b'-'))
318
+ return digest.hexdigest()[:16]
319
+ except Exception:
320
+ return None
321
+
322
+
323
+ def forge_rev() -> str | None:
324
+ """HEAD of the forge checkout, or None for a copied skill dir. Read-only: no index refresh."""
325
+ if not (SKILL / '.git').exists():
326
+ return None
327
+ try:
328
+ result = subprocess.run(['git', '-C', str(SKILL), 'rev-parse', 'HEAD'], stdout=subprocess.PIPE,
329
+ stderr=subprocess.DEVNULL, timeout=2, env=dict(os.environ, GIT_OPTIONAL_LOCKS='0'))
330
+ except (OSError, subprocess.SubprocessError):
331
+ return None
332
+ rev = result.stdout.decode('ascii', 'replace').strip()
333
+ return rev if result.returncode == 0 and len(rev) >= 40 else None
334
+
335
+
336
+ def repo_id(path: str | Path) -> str:
337
+ """Same scheme as the runs registry, so the two join on one key."""
338
+ return forge_registry.repo_key(path)
339
+
340
+
341
+ def git_origin(repo: str | Path) -> str | None:
342
+ """The origin URL, read from .git/config without running git."""
343
+ try:
344
+ config = Path(repo) / '.git' / 'config'
345
+ if not config.is_file():
346
+ return None
347
+ section = None
348
+ for line in config.read_text(errors='replace').splitlines():
349
+ stripped = line.strip()
350
+ if stripped.startswith('['):
351
+ section = stripped
352
+ elif section == '[remote "origin"]' and stripped.startswith('url'):
353
+ return stripped.split('=', 1)[1].strip()
354
+ except OSError:
355
+ return None
356
+ return None
357
+
358
+
359
+ def register_repo(db: sqlite3.Connection, path: str | Path | None) -> str | None:
360
+ if not path:
361
+ return None
362
+ key = repo_id(path)
363
+ now = time.time()
364
+ db.execute('INSERT OR IGNORE INTO repos(repo, path, origin, first_seen, last_seen) '
365
+ 'VALUES (?, ?, ?, ?, ?)', (key, str(Path(path).resolve()), git_origin(path), now, now))
366
+ db.execute('UPDATE repos SET last_seen = ? WHERE repo = ?', (now, key))
367
+ return key
368
+
369
+
370
+ def insert(db: sqlite3.Connection, event: dict) -> bool:
371
+ """Append one event. The first writer of a rid wins, so import and live never duplicate."""
372
+ row = dict(event)
373
+ row.setdefault('src', 'live')
374
+ if row['src'] != 'import':
375
+ # An imported run was produced by whatever forge ran it, not by the importer, so its
376
+ # provenance stays unknown. Rows imported before schema 2 carry the importer's
377
+ # version: readers treat forge_version/registry_sha on src='import' as unknown.
378
+ row.update({k: v for k, v in provenance().items() if row.get(k) is None})
379
+ row.setdefault('ts', time.time())
380
+ row['data'] = json.dumps(row.get('data') or {}, sort_keys=True, default=str)
381
+ values = [row.get(column) for column in EVENT_COLUMNS]
382
+ cursor = db.execute('INSERT OR IGNORE INTO events(%s) VALUES (%s)'
383
+ % (', '.join(EVENT_COLUMNS), ', '.join('?' * len(EVENT_COLUMNS))), values)
384
+ return cursor.rowcount == 1
385
+
386
+
387
+ def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
388
+ """Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
389
+ if not enabled() or not events:
390
+ return 0
391
+ try:
392
+ # Refused silently: errors.log lives in the same refused place.
393
+ if location_refused(repo):
394
+ return 0
395
+ # Fingerprinting the code reads every shipped file and runs git once per process:
396
+ # done under the write lock, it held every other writer past its busy timeout.
397
+ provenance()
398
+ db = connect()
399
+ try:
400
+ db.execute('BEGIN IMMEDIATE')
401
+ key = register_repo(db, repo) if repo else None
402
+ added = 0
403
+ for event in events:
404
+ if key and not event.get('repo'):
405
+ event['repo'] = key
406
+ added += insert(db, event)
407
+ db.execute('COMMIT')
408
+ return added
409
+ except BaseException:
410
+ try:
411
+ db.execute('ROLLBACK')
412
+ except sqlite3.Error:
413
+ pass
414
+ raise
415
+ finally:
416
+ db.close()
417
+ except Exception as error:
418
+ log_error(where, error)
419
+ return 0
420
+
421
+
422
+ def create_once(path: Path, text: str) -> str:
423
+ """Write `text` to `path` unless it exists; return whichever content won.
424
+
425
+ O_EXCL makes two concurrent first captures agree on one value instead of each
426
+ replacing the other's.
427
+ """
428
+ try:
429
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
430
+ except FileExistsError:
431
+ for _ in range(20):
432
+ existing = path.read_text().strip()
433
+ if existing:
434
+ return existing
435
+ time.sleep(0.01)
436
+ return path.read_text().strip() or text
437
+ with os.fdopen(fd, 'w') as handle:
438
+ handle.write(text + '\n')
439
+ return text