@giamme/forge 0.0.0-stage → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,262 @@
1
+ """Mandatory, durable planning decisions for opt-in automatic task trees."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import uuid
9
+
10
+ from . import artifact, write_json
11
+
12
+
13
+ WORK_PROTOCOL = '''
14
+ Forge has already made this node's decomposition decision. Implement the remaining
15
+ scoped work, integrate completed child results, and run the requested verification.
16
+ Do not spawn children or change control metadata. End your final response with:
17
+ FORGE_FRACTAL: {"done":true}
18
+ '''
19
+
20
+ PLAN_PROTOCOL = '''
21
+ FORGE_DECOMPOSITION_DECISION
22
+ This is a planning-only invocation. Inspect the repository without changing files,
23
+ running implementation, or using Fractal control commands. The approved task contract
24
+ and ownership are authoritative; decide only how to execute this node within them.
25
+ Prefer splitting nontrivial work into useful, independently verifiable children.
26
+ Keep genuinely atomic or tightly coupled work together and explain why.
27
+ Child goals must contain complete instructions and verification requirements.
28
+ Use globally unique child IDs, declared paths within the node's ownership, and sibling
29
+ dependencies for ordering. Existing completed work must be preserved, not delegated again.
30
+ Your children receive this same planning stage automatically. Do not force needless depth.
31
+ Return exactly one final-line JSON marker, with a nonempty reason:
32
+ FORGE_FRACTAL: {"decision":"atomic","reason":"why this work should remain together"}
33
+ or:
34
+ FORGE_FRACTAL: {"decision":"split","reason":"why this split is useful","children":[
35
+ {"id":"unique-name","goal":"complete instructions","difficulty":"low","paths":["relative/path"],"deps":[]},
36
+ {"id":"another-name","goal":"complete instructions","difficulty":"medium","paths":["another/path"],"deps":[]}]}
37
+ The split marker must be on ONE line and contain at least two children. Do not return done.
38
+ '''
39
+
40
+
41
+ def parse_decision(text: str) -> dict:
42
+ markers = [line for line in text.splitlines() if line.startswith('FORGE_FRACTAL: ')]
43
+ if len(markers) != 1 or text.strip().splitlines()[-1] != markers[0]:
44
+ raise ValueError('Expected exactly one final FORGE_FRACTAL decision marker')
45
+ value = json.loads(markers[0].split(': ', 1)[1])
46
+ if not isinstance(value, dict) or value.get('decision') not in ('split', 'atomic'):
47
+ raise ValueError('Planner must decide split or atomic; done is not a planning decision')
48
+ if not isinstance(value.get('reason'), str) or not value['reason'].strip():
49
+ raise ValueError('Decomposition decision requires a nonempty reason')
50
+ allowed = {'decision', 'reason', 'children'} if value['decision'] == 'split' else {'decision', 'reason'}
51
+ if set(value) != allowed:
52
+ raise ValueError('Unexpected or missing decomposition decision fields')
53
+ if value['decision'] == 'split':
54
+ children = value['children']
55
+ if not isinstance(children, list) or len(children) < 2:
56
+ raise ValueError('A split requires at least two children')
57
+ for child in children:
58
+ _validate_child_shape(child)
59
+ return value
60
+
61
+
62
+ def _validate_child_shape(child: dict) -> None:
63
+ required = {'id', 'goal', 'difficulty', 'paths'}
64
+ if not isinstance(child, dict) or not required <= child.keys() or child.keys() - required - {'deps'}:
65
+ raise ValueError('Invalid child fields')
66
+ if any(not isinstance(child[key], str) or not child[key].strip() for key in ('id', 'goal', 'difficulty')):
67
+ raise ValueError('Child id, goal and difficulty must be nonempty strings')
68
+ for key in ('paths', 'deps'):
69
+ values = child.get(key, [])
70
+ if not isinstance(values, list) or any(not isinstance(item, str) for item in values):
71
+ raise ValueError('Child paths and deps must be lists of strings')
72
+
73
+
74
+ def manifests(node: dict) -> list[dict]:
75
+ return [*node.get('decomposition_history', []), node.get('decomposition', {})]
76
+
77
+
78
+ def reserved_nodes(nodes: dict) -> dict:
79
+ """Count accepted but not yet materialized children against lifetime bounds."""
80
+ result = dict(nodes)
81
+ for node in nodes.values():
82
+ for decision in manifests(node):
83
+ for child in decision.get('admission', []):
84
+ result.setdefault(child['name'], dict(id=child['name'], parent=node['id'], status='reserved'))
85
+ return result
86
+
87
+
88
+ def capacity(tree, node: dict) -> dict:
89
+ nodes = reserved_nodes(tree.nodes())
90
+ unsettled = sum(n['parent'] == node['id'] and n['status'] != 'completed' for n in nodes.values())
91
+ return dict(depth=len(node['ancestors']), max_depth=tree.limits['depth'],
92
+ children=tree.limits['children'] - unsettled, nodes=tree.limits['nodes'] - len(nodes))
93
+
94
+
95
+ def bound_reason(bounds: dict) -> str:
96
+ if bounds['depth'] >= bounds['max_depth']:
97
+ return 'depth limit reached'
98
+ if bounds['children'] < 2:
99
+ return 'fewer than two direct child slots available'
100
+ if bounds['nodes'] < 2:
101
+ return 'fewer than two lifetime node slots available'
102
+ return ''
103
+
104
+
105
+ def implementation_prompt(tree, node: dict) -> str:
106
+ from .execution import PROTOCOL
107
+ automatic = tree.config.get('decomposition', {}).get('enabled', False)
108
+ prompt = node['goal'] + (WORK_PROTOCOL if automatic else PROTOCOL)
109
+ prompt += '\nOwned paths: ' + json.dumps(node['paths'])
110
+ if automatic:
111
+ prompt += '\nApproved task contract:\n' + tree.request['prompt']
112
+ children = [n for n in tree.nodes().values() if n['parent'] == node['id']]
113
+ if children:
114
+ prompt += '\nCompleted child results (already imported):\n' + json.dumps(children)
115
+ if node.get('last'):
116
+ prompt += '\nPrevious work is preserved. Prior response:\n' + node['last']
117
+ return prompt
118
+
119
+
120
+ def retry_decision(node: dict, prompt: str) -> None:
121
+ decision = node.get('decomposition')
122
+ changed_goal = node['id'] == 'implementation' and node['goal'] != prompt
123
+ if decision and (changed_goal or decision['phase'] != 'accepted'):
124
+ node.setdefault('decomposition_history', []).append(node.pop('decomposition'))
125
+ node.pop('error', None)
126
+
127
+
128
+ def snapshot_fingerprint(workspace: Path) -> str:
129
+ """Include ignored/product files as well as HEAD and staged content."""
130
+ digest = hashlib.sha256(artifact('forge_fingerprint', workspace).encode())
131
+ for root, dirs, files in os.walk(workspace, followlinks=False):
132
+ if Path(root) == workspace:
133
+ dirs[:] = [name for name in dirs if name != '.git']
134
+ for name in sorted(dirs + files):
135
+ path = Path(root) / name
136
+ digest.update(str(path.relative_to(workspace)).encode())
137
+ digest.update(str(path.lstat().st_mode).encode())
138
+ if path.is_symlink():
139
+ digest.update(os.readlink(path).encode())
140
+ elif path.is_file():
141
+ with path.open('rb') as stream:
142
+ for block in iter(lambda: stream.read(65536), b''):
143
+ digest.update(block)
144
+ dirs.sort()
145
+ return digest.hexdigest()
146
+
147
+
148
+ class DecisionStage:
149
+ def __init__(self, tree) -> None:
150
+ self.tree = tree
151
+
152
+ def save(self, node: dict, decision: dict) -> None:
153
+ from .execution import event
154
+ self.tree.update(node, decomposition=decision)
155
+ event(self.tree.task, 'decomposition_decision', node=node['id'],
156
+ phase=decision['phase'], decision=decision.get('decision'), reason=decision.get('reason'))
157
+
158
+ def recover_admission(self, node: dict) -> None:
159
+ # A manifest is the durable reservation and fixes routing before any spawn.
160
+ with self.tree.guard:
161
+ for decision in manifests(node):
162
+ for child in decision.get('admission', []):
163
+ if child['name'] not in self.tree.nodes():
164
+ self.tree.create_node(parent=node, **child)
165
+ self.tree.update(node)
166
+
167
+ def ensure(self, node: dict, workspace: Path) -> None:
168
+ from .execution import Halt
169
+ self.recover_admission(node)
170
+ if node.get('awaiting_children'):
171
+ self.tree.children(node)
172
+ self.tree.update(node, awaiting_children=False)
173
+ decision = node.get('decomposition')
174
+ if decision and decision['phase'] == 'accepted':
175
+ return
176
+ if decision and decision['phase'] == 'failed':
177
+ raise Halt(decision['error'])
178
+ if decision is None:
179
+ decision = dict(phase='pending', attempts=0,
180
+ goal_sha256=hashlib.sha256(node['goal'].encode()).hexdigest(),
181
+ planner=self.tree.request['planner'])
182
+ self.save(node, decision)
183
+ while decision['attempts'] < 2:
184
+ self.tree.pause_gate(node)
185
+ bounds = capacity(self.tree, node)
186
+ if reason := bound_reason(bounds):
187
+ self.save(node, dict(decision, phase='accepted', decision='bounded', reason=reason, bounds=bounds))
188
+ return
189
+ decision.update(phase='planning', attempts=decision['attempts'] + 1, bounds=bounds)
190
+ self.save(node, decision)
191
+ try:
192
+ text = self.invoke(node, workspace, decision)
193
+ proposal = parse_decision(text)
194
+ self.accept(node, decision, proposal)
195
+ except Halt as error:
196
+ if str(error) == 'paused':
197
+ decision.update(phase='pending', attempts=decision['attempts'] - 1)
198
+ self.save(node, decision)
199
+ continue
200
+ if str(error) not in ('stopped', 'interrupted', 'timeout'):
201
+ self.save(node, dict(decision, phase='failed', error=str(error)))
202
+ raise
203
+ except (ValueError, KeyError, TypeError) as error:
204
+ decision.update(phase='pending', feedback=str(error))
205
+ self.save(node, decision)
206
+ continue
207
+ self.recover_admission(node)
208
+ if node.get('awaiting_children'):
209
+ self.tree.children(node)
210
+ self.tree.update(node, awaiting_children=False)
211
+ return
212
+ self.save(node, dict(decision, phase='failed', error='planning_exhausted'))
213
+ raise Halt('planning_exhausted')
214
+
215
+ def invoke(self, node: dict, workspace: Path, decision: dict) -> str:
216
+ from .execution import Halt
217
+ from .invocation import run_step
218
+ directory = self.tree.task / 'nodes' / node['id'] / 'planning' / uuid.uuid4().hex
219
+ snapshot = directory / 'product'
220
+ base = artifact('forge_tree', workspace)
221
+ source_before = artifact('forge_fingerprint', workspace)
222
+ artifact('forge_review_snapshot', workspace, base, base, snapshot)
223
+ before = snapshot_fingerprint(snapshot)
224
+ children = [n for n in self.tree.nodes().values() if n['parent'] == node['id']]
225
+ context = dict(node=node['id'], goal=node['goal'], owned_paths=node['paths'],
226
+ bounds=decision['bounds'], routing=self.tree.config['routing'],
227
+ children=children, feedback=decision.get('feedback'),
228
+ existing_ids=list(reserved_nodes(self.tree.nodes())))
229
+ prompt = ('Approved task contract:\n' + self.tree.request['prompt'] + '\n' + PLAN_PROTOCOL
230
+ + '\nNode planning context:\n' + json.dumps(context))
231
+ write_json(directory / 'request.json', context)
232
+ try:
233
+ rc, text = run_step(self.tree, node, snapshot, prompt, phase='planning')
234
+ finally:
235
+ try:
236
+ unchanged = snapshot_fingerprint(snapshot) == before
237
+ except (OSError, RuntimeError):
238
+ unchanged = False
239
+ if not unchanged or artifact('forge_fingerprint', workspace) != source_before:
240
+ raise Halt('planning_mutation')
241
+ self.tree.update(node, planning_last=text)
242
+ if rc:
243
+ raise Halt('timeout' if rc == 7 else 'planning_failed')
244
+ return text
245
+
246
+ def accept(self, node: dict, decision: dict, proposal: dict) -> None:
247
+ with self.tree.guard:
248
+ bounds = capacity(self.tree, node)
249
+ reason = bound_reason(bounds)
250
+ needed = len(proposal.get('children', []))
251
+ offered = decision.get('bounds', bounds)
252
+ if min(bounds['children'], bounds['nodes']) < needed <= min(offered['children'], offered['nodes']):
253
+ reason = 'child capacity consumed by another admission during planning'
254
+ if proposal['decision'] == 'split' and reason:
255
+ proposal = dict(decision='bounded', reason=reason)
256
+ admission = []
257
+ if proposal['decision'] == 'split':
258
+ admission = self.tree.validate_children(node, proposal['children'])
259
+ accepted = dict(decision, **proposal, phase='accepted', bounds=bounds, admission=admission)
260
+ # Decision and awaiting state share one atomic node.json write.
261
+ node['awaiting_children'] = bool(admission)
262
+ self.save(node, accepted)