@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,244 @@
1
+ """Plan-time semantic wave coupling.
2
+
3
+ `compute_waves` bounds parallelism structurally: two tasks share a wave only when their
4
+ declared `files` are disjoint. Disjointness is blind to the conflict where task A changes
5
+ an interface task B consumes -- both diffs pass review against their own baseline, and the
6
+ merge is broken (see questions.wave_coupling). This asks about every pair `compute_waves`
7
+ would actually run concurrently, before the wave runs, so the warning still lands while the
8
+ plan can change.
9
+
10
+ Nothing here writes to tasks.tsv or waves.tsv. Scoring is advisory and silent in shadow
11
+ mode, exactly like routing.py and gates.py.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from . import threshold
18
+ from .client import ask, noul
19
+ from .questions import forward_dependency, wave_coupling
20
+
21
+ # A wave of 12 tasks is 66 pairs; a plan-time gate must not become the slowest thing in
22
+ # `forge plan`. Sorted before truncation (see pairs()) so the same plan always produces
23
+ # the same capped set.
24
+ MAX_PAIRS = 60
25
+
26
+ # How many pairs a plan may warn about, however many clear the threshold.
27
+ #
28
+ # Rank is the signal here, not the absolute value. Over 11 pairs with known outcomes the
29
+ # genuinely coupled ones took 4 of the top 5 places, but the absolute scores moved with
30
+ # the plan: the same rubric put the whole distribution at 0.09-0.27 in one measurement
31
+ # and 0.20-0.38 on the live 8-task plan. At a fixed 0.20 that live plan warned about 12
32
+ # of its 21 pairs, and a gate that fires on more than half of everything teaches people
33
+ # to ignore it -- which is the argument this file's own threshold comment makes about
34
+ # using one number for several rubrics.
35
+ #
36
+ # So the threshold decides whether anything is worth saying and the ordering decides
37
+ # what gets said. Three is what fits in a plan summary a human will actually read.
38
+ MAX_WARNINGS = 3
39
+
40
+
41
+ # Cross-wave pairs get their own budget rather than sharing MAX_PAIRS: a plan with one
42
+ # big first wave would otherwise spend the whole allowance inside it and never ask the
43
+ # ordering question at all, which is the one that shipped a defect.
44
+ MAX_FORWARD_PAIRS = 40
45
+
46
+ PROMPT_CHARS = 2500
47
+
48
+
49
+ def parse_tasks(plan: Path) -> dict[str, dict]:
50
+ """id -> {id, title, files, deps, difficulty, prompt}, skipping comments and malformed rows."""
51
+ path = plan / 'tasks.tsv'
52
+ tasks = {}
53
+ try:
54
+ lines = path.read_text(errors='replace').splitlines()
55
+ except OSError:
56
+ return tasks
57
+ for line in lines:
58
+ if not line.strip() or line.startswith('#'):
59
+ continue
60
+ parts = line.split('\t')
61
+ if len(parts) < 7:
62
+ continue
63
+ task_id, deps, difficulty, files, _dwarf, _qa, title = parts[:7]
64
+ if not task_id:
65
+ continue
66
+ # The requirements, not the implementation. An interface dependency between two
67
+ # tasks is written down here and nowhere else -- certainly not in a one-line
68
+ # title, which is all this rubric used to get.
69
+ prompt = ''
70
+ prompt_file = plan / 'tasks' / task_id / 'prompt.md'
71
+ try:
72
+ if prompt_file.is_file():
73
+ prompt = prompt_file.read_text(errors='replace')[:PROMPT_CHARS]
74
+ except OSError:
75
+ prompt = ''
76
+ tasks[task_id] = dict(id=task_id, title=title, files=files, deps=deps,
77
+ difficulty=difficulty, prompt=prompt)
78
+ return tasks
79
+
80
+
81
+ def parse_waves(plan: Path) -> dict[str, list[str]]:
82
+ """wave number (as it appears in waves.tsv) -> ordered list of task ids."""
83
+ path = plan / 'waves.tsv'
84
+ waves: dict[str, list[str]] = {}
85
+ try:
86
+ lines = path.read_text(errors='replace').splitlines()
87
+ except OSError:
88
+ return waves
89
+ for line in lines:
90
+ parts = line.split('\t')
91
+ if len(parts) < 2:
92
+ continue
93
+ wave, task_id = parts[0], parts[1]
94
+ if not wave or not task_id:
95
+ continue
96
+ waves.setdefault(wave, []).append(task_id)
97
+ return waves
98
+
99
+
100
+ def pairs(tasks: dict, waves: dict) -> list[tuple[str, str]]:
101
+ """Every same-wave task pair with both ids known, capped at MAX_PAIRS.
102
+
103
+ Sorted before truncation, same reasoning as gates.candidates: an unstable slice would
104
+ make the same plan warn about a different subset of pairs on two runs.
105
+ """
106
+ found = []
107
+ for ids in waves.values():
108
+ ordered = sorted(i for i in ids if i in tasks)
109
+ for i, first in enumerate(ordered):
110
+ for second in ordered[i + 1:]:
111
+ found.append((first, second))
112
+ found.sort()
113
+ return found[:MAX_PAIRS]
114
+
115
+
116
+ def _wave_order(waves: dict) -> dict[str, int]:
117
+ """Task id -> wave position. Waves are numbered as strings in waves.tsv; sort them
118
+ numerically where possible so '10' follows '9' rather than '1'."""
119
+ def key(name):
120
+ try:
121
+ return (0, int(name))
122
+ except ValueError:
123
+ return (1, name)
124
+
125
+ order = {}
126
+ for position, wave in enumerate(sorted(waves, key=key)):
127
+ for task_id in waves[wave]:
128
+ order[task_id] = position
129
+ return order
130
+
131
+
132
+ def forward_pairs(tasks: dict, waves: dict) -> list[tuple[str, str]]:
133
+ """Ordered (earlier, later) pairs across waves that nothing forces to be safe.
134
+
135
+ `pairs()` only looks inside a wave, so "task A is written against an interface task B
136
+ builds later" was structurally outside this gate's reach -- and that is what shipped:
137
+ jevscope's `readme` ran in wave 1 documenting the CLI that wave 2 built, passed its
138
+ own review, and described an interface nobody ever wrote.
139
+
140
+ A declared dependency is excluded, in the direction that matters: if LATER is named
141
+ in EARLIER's deps, forge would not have placed EARLIER first, so the pair cannot
142
+ arise. Nothing else about wave placement makes the order safe.
143
+ """
144
+ order = _wave_order(waves)
145
+ found = []
146
+ for earlier in sorted(tasks):
147
+ for later in sorted(tasks):
148
+ if earlier == later:
149
+ continue
150
+ if earlier not in order or later not in order:
151
+ continue
152
+ if order[earlier] >= order[later]:
153
+ continue
154
+ declared = {d.strip() for d in str(tasks[earlier].get('deps', '')).split(',')}
155
+ if later in declared:
156
+ continue
157
+ found.append((earlier, later))
158
+ found.sort()
159
+ return found[:MAX_FORWARD_PAIRS]
160
+
161
+
162
+ def forward_dependencies(repo, *, tasks, waves, run_dir=None, config: dict | None = None):
163
+ """Earlier-wave tasks Jev thinks were written against work that lands later.
164
+
165
+ Same contract as coupled_pairs: [(earlier, later, probability), ...] highest first,
166
+ [] for "nothing to say", None for "no judgment could be made".
167
+ """
168
+ by_id = {t['id']: t for t in tasks if isinstance(t, dict) and t.get('id')}
169
+ candidates = forward_pairs(by_id, waves)
170
+ if not candidates:
171
+ return []
172
+
173
+ questions = {}
174
+ for i, (earlier, later) in enumerate(candidates):
175
+ earlier_desc = f"{earlier} ({by_id[earlier]['title']}) files: {by_id[earlier]['files']}"
176
+ later_desc = f"{later} ({by_id[later]['title']}) files: {by_id[later]['files']}"
177
+ questions[f'f{i}'] = forward_dependency(earlier_desc, later_desc)
178
+
179
+ involved = sorted({task_id for pair in candidates for task_id in pair})
180
+ state = dict(tasks=[dict(id=by_id[i]['id'], title=by_id[i]['title'],
181
+ files=by_id[i]['files'],
182
+ requirements=by_id[i].get('prompt', ''))
183
+ for i in involved])
184
+ result = ask(state, questions, site='jev-forward', run_dir=run_dir, config=config)
185
+ if result is None:
186
+ return None
187
+
188
+ warn_at = threshold('forward_warn', config=config)
189
+ found = []
190
+ for i, (earlier, later) in enumerate(candidates):
191
+ probability = noul(result, f'f{i}')
192
+ if probability is not None and probability >= warn_at:
193
+ found.append((earlier, later, probability))
194
+ found.sort(key=lambda row: (-row[2], row[0], row[1]))
195
+ return found[:MAX_WARNINGS]
196
+
197
+
198
+ def coupled_pairs(repo, *, tasks, waves, run_dir=None, config: dict | None = None):
199
+ """Same-wave task pairs Jev thinks would still conflict once merged.
200
+
201
+ `tasks` is a list of dicts with id/title/files/difficulty; `waves` maps a wave number
202
+ to the list of task ids placed in it -- the same shapes `compute_waves` produces.
203
+ Returns [(id_a, id_b, probability), ...] at or above threshold('gate_warn'), highest
204
+ probability first. Returns [] when there is nothing to warn about and None when no
205
+ judgment could be made, so a caller can tell "no coupling found" from "Jev did not
206
+ answer".
207
+ """
208
+ by_id = {t['id']: t for t in tasks if isinstance(t, dict) and t.get('id')}
209
+ candidate_pairs = pairs(by_id, waves)
210
+ if not candidate_pairs:
211
+ return []
212
+
213
+ questions = {}
214
+ for i, (first, second) in enumerate(candidate_pairs):
215
+ first_desc = f"{first} ({by_id[first]['title']}) files: {by_id[first]['files']}"
216
+ second_desc = f"{second} ({by_id[second]['title']}) files: {by_id[second]['files']}"
217
+ questions[f'p{i}'] = wave_coupling(first_desc, second_desc)
218
+
219
+ # No file CONTENTS in state -- the rubric asks about interfaces between tasks, not
220
+ # implementations, and sending diffs nobody wrote yet would only add noise and cost.
221
+ # The task's requirements are a different thing, and they are where an interface
222
+ # dependency is actually written down. Without them this rubric had no signal at all:
223
+ # measured over 11 pairs with known outcomes, the two that produced a defect that
224
+ # really shipped scored LOWEST of everything, below pairs that were unrelated. With
225
+ # them, 4 of the 5 coupled pairs rank above every independent one.
226
+ #
227
+ # Only the tasks appearing in a candidate pair are sent, not the whole plan.
228
+ involved = sorted({task_id for pair in candidate_pairs for task_id in pair})
229
+ state = dict(tasks=[dict(id=by_id[i]['id'], title=by_id[i]['title'],
230
+ files=by_id[i]['files'],
231
+ requirements=by_id[i].get('prompt', ''))
232
+ for i in involved])
233
+ result = ask(state, questions, site='jev-coupling', run_dir=run_dir, config=config)
234
+ if result is None:
235
+ return None
236
+
237
+ warn_at = threshold('coupling_warn', config=config)
238
+ found = []
239
+ for i, (first, second) in enumerate(candidate_pairs):
240
+ probability = noul(result, f'p{i}')
241
+ if probability is not None and probability >= warn_at:
242
+ found.append((first, second, round(probability, 3)))
243
+ found.sort(key=lambda item: item[2], reverse=True)
244
+ return found[:MAX_WARNINGS]
@@ -0,0 +1,24 @@
1
+ """Plain numeric features of a task, for a decision model that reads a routing state.
2
+
3
+ A task's size is the strongest regime signal in forge's run history: the median prompt of
4
+ the newest runs is twice the older ones' and their first-pass rate is half. A model reading
5
+ prose and a 40-entry sibling list can only estimate that, so the state spells it out.
6
+
7
+ The dataset export adds this block to the routing state it trains on. Hosted Jev's live
8
+ request does not carry it (changing that request changes every fixture key), so a local
9
+ model wired in later must send the same block, with the exact plan size and decompose
10
+ level: the live state caps siblings at 40 and has no level. No I/O here; callers pass values.
11
+ """
12
+ from __future__ import annotations
13
+
14
+
15
+ def _count(value) -> int:
16
+ if isinstance(value, (list, tuple)):
17
+ return len([v for v in value if v])
18
+ return len([w for w in str(value or '').replace(',', ' ').split() if w and w != '-'])
19
+
20
+
21
+ def task_size(*, prompt: str, declared_files, plan_tasks: int, deps, decompose_level: str | None) -> dict:
22
+ return dict(prompt_chars=len(prompt or ''), declared_files=_count(declared_files),
23
+ plan_tasks=int(plan_tasks), deps=_count(deps),
24
+ decompose_level=decompose_level if decompose_level in ('low', 'medium', 'high') else None)
@@ -0,0 +1,100 @@
1
+ """Plan-time `files` drift prediction.
2
+
3
+ `decompose.md` calls the `files` column "a promise, not a prediction": a task that edits
4
+ a file it never declared breaks wave disjointness, which is the only thing bounding
5
+ parallelism. Forge already detects this, but afterwards, in `drift.txt` -- by which point
6
+ the wave has run. Asking at plan time converts a post-mortem into a warning while the
7
+ plan can still change.
8
+
9
+ Measured before shipping (see references/jev.md): at the default `gate_warn` of 0.60 this
10
+ rubric scored precision 1.000 on one repo and 0.934 on another, over 50 commits. Precision
11
+ is the number that matters here -- a warning that fires on files nobody edits teaches
12
+ people to ignore every future warning.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from . import threshold
20
+ from .client import ask, noul
21
+ from .questions import drift_prediction
22
+
23
+ # One request per task holds this many questions comfortably, and a plan-time gate must
24
+ # not turn into the slowest thing in `forge plan`.
25
+ CANDIDATE_CAP = 120
26
+
27
+ # Files nobody means when they say "did this task edit something it did not declare".
28
+ _SKIP_PREFIXES = ('.git/', '.forge/', 'node_modules/', 'vendor/', 'target/', 'dist/',
29
+ 'build/', '.venv/', 'venv/', '__pycache__/')
30
+ _SKIP_SUFFIXES = ('.lock', '.min.js', '.map', '.png', '.jpg', '.jpeg', '.gif', '.svg',
31
+ '.ico', '.pdf', '.zip', '.woff', '.woff2', '.ttf')
32
+ # Lockfiles whose name the suffix list cannot catch. They are generated, enormous, and
33
+ # genuinely do change whenever dependencies do, which is what would make a drift warning
34
+ # about them both technically true and completely useless.
35
+ _SKIP_NAMES = ('package-lock.json', 'npm-shrinkwrap.json', 'composer.lock',
36
+ 'Gemfile.lock', 'go.sum', 'pnpm-lock.yaml', 'yarn.lock')
37
+
38
+
39
+ def _tracked(repo: Path) -> list[str]:
40
+ try:
41
+ out = subprocess.run(['git', '-C', str(repo), 'ls-files', '-z'],
42
+ capture_output=True, text=True, timeout=20)
43
+ except (OSError, subprocess.SubprocessError):
44
+ return []
45
+ if out.returncode != 0:
46
+ return []
47
+ return [name for name in out.stdout.split('\0') if name]
48
+
49
+
50
+ def candidates(repo, declared: str) -> list[str]:
51
+ """Tracked files the task does NOT declare, capped.
52
+
53
+ Sorted before truncation so the slice is deterministic: an unstable candidate set
54
+ would make the same plan warn differently on two runs.
55
+ """
56
+ owned = set((declared or '').replace(',', ' ').split())
57
+ found = []
58
+ for name in sorted(_tracked(Path(repo))):
59
+ if name in owned:
60
+ continue
61
+ if name.startswith(_SKIP_PREFIXES) or name.endswith(_SKIP_SUFFIXES):
62
+ continue
63
+ if name.rsplit('/', 1)[-1] in _SKIP_NAMES:
64
+ continue
65
+ found.append(name)
66
+ if len(found) >= CANDIDATE_CAP:
67
+ break
68
+ return found
69
+
70
+
71
+ def predict_drift(repo, *, goal: str, task_id: str, title: str, declared: str,
72
+ approach: str = '', prompt: str = '', run_dir=None,
73
+ config: dict | None = None):
74
+ """Undeclared files this task looks likely to edit, highest probability first.
75
+
76
+ Returns [] when there is nothing to say and None when no judgment could be made, so
77
+ a caller can tell "no drift predicted" from "Jev did not answer".
78
+ """
79
+ pool = candidates(repo, declared)
80
+ if not pool:
81
+ return []
82
+ questions = {f'f{i}': drift_prediction(name) for i, name in enumerate(pool)}
83
+ # Which files a task will really touch is stated in its requirements far more often
84
+ # than in its title -- "extend tests/test_events.py" is in the prompt, not the title.
85
+ state = dict(goal=goal, task=dict(id=task_id, title=title, declared_files=declared),
86
+ prompt=prompt, approach=approach)
87
+ result = ask(state, questions, site='jev-drift', run_dir=run_dir, config=config)
88
+ if result is None:
89
+ return None
90
+
91
+ # drift_warn reads gate_warn until a model has its own measured value: a cutoff fitted
92
+ # to one model's probabilities is not a cutoff on another's.
93
+ warn_at = threshold('drift_warn', config=config, model=result.get('model'))
94
+ predicted = []
95
+ for i, name in enumerate(pool):
96
+ probability = noul(result, f'f{i}')
97
+ if probability is not None and probability >= warn_at:
98
+ predicted.append((name, round(probability, 3)))
99
+ predicted.sort(key=lambda item: item[1], reverse=True)
100
+ return predicted
@@ -0,0 +1,163 @@
1
+ """Curation of what reaches `.forge/memory.md`.
2
+
3
+ The stakes are asymmetric, and that shapes every default here. `memory.md` is injected
4
+ into every dispatch of every future run, so a wrong fact is paid for forever and gets
5
+ corrected only when a human happens to read it. The ledger is append-only and never
6
+ injected, so **nothing recorded is ever lost** -- this gates injection, not recording.
7
+
8
+ That asymmetry is why each judgment below fails toward today's behaviour rather than
9
+ toward acting: an unreachable API, a missing key, a malformed answer and a disabled
10
+ capability all leave the model's own category and key exactly as they were.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from . import threshold
15
+ from .client import ask, choice, noul
16
+ from .questions import memory_category, memory_durable, memory_same_fact
17
+
18
+ CATEGORIES = ('verify', 'trap', 'finding')
19
+
20
+ # Enough context to match a paraphrase without turning one dispatch's bookkeeping into
21
+ # the largest request in the run.
22
+ MAX_EXISTING = 40
23
+ MAX_TEXT = 500
24
+
25
+
26
+ def curate(*, category: str, text: str, existing=None, run_dir=None,
27
+ config: dict | None = None) -> dict | None:
28
+ """Three judgments in one request: is it a duplicate, is it durable, is it filed right.
29
+
30
+ Returns None when nothing could be judged, which callers must treat as "keep what the
31
+ model said". The returned dict carries only *suggestions*; `key` is None unless a
32
+ paraphrase was matched confidently enough to merge.
33
+ """
34
+ text = (text or '').strip()[:MAX_TEXT]
35
+ if not text:
36
+ return None
37
+ entries = [line for line in (existing or []) if line][:MAX_EXISTING]
38
+
39
+ questions = dict(durable=memory_durable(text), category=memory_category(text))
40
+ if entries:
41
+ # Choice needs at least two options; with no existing entries there is nothing
42
+ # to be a duplicate of, so the question is not worth asking.
43
+ questions['same'] = memory_same_fact(text, entries)
44
+
45
+ result = ask(dict(recorded=text, existing=entries), questions,
46
+ site='jev-memory-curate', run_dir=run_dir, config=config)
47
+ if result is None:
48
+ return None
49
+
50
+ durable = noul(result, 'durable')
51
+ suggested, category_confidence = choice(result, 'category')
52
+ matched, match_confidence = (choice(result, 'same') if entries else (None, None))
53
+
54
+ out = dict(durable=durable, category=category, key=None, matched=None,
55
+ suggested_category=suggested, category_confidence=category_confidence)
56
+
57
+ # A paraphrase only merges above the threshold. Merging two facts that merely look
58
+ # alike loses one of them permanently, while a duplicate is only untidy -- so the
59
+ # bar sits on the side of keeping both.
60
+ if (matched and matched != 'none' and match_confidence is not None
61
+ and match_confidence >= threshold('memory_merge', config=config)):
62
+ out['matched'] = matched
63
+ out['match_confidence'] = match_confidence
64
+
65
+ # Category is corrected only toward a real category. 'none' means the model could not
66
+ # place it, which is a reason to leave the recorded category alone, not to discard it.
67
+ if (suggested in CATEGORIES and suggested != category
68
+ and category_confidence is not None
69
+ and category_confidence >= threshold('memory_recategorize', config=config)):
70
+ out['category'] = suggested
71
+
72
+ # The quality gate. Silence is the safe direction: a rejected line still lands in the
73
+ # ledger in full, so the only thing lost is injection into future prompts.
74
+ out['injectable'] = not (durable is not None and durable < threshold('gate_warn', config=config))
75
+
76
+ # A line that merged onto an existing entry is exempt, and the two judgments have to
77
+ # be read together to see why. `rebuild` skips a non-injectable row before it counts
78
+ # anything, and a `finding` needs two DISTINCT runs before it is promoted -- which is
79
+ # the whole reason dedup exists, since two runs wording one fact differently would
80
+ # otherwise never reach two. So a restatement judged not durable is dropped from the
81
+ # count of the fact it restates, defeating dedup in exactly the case it was built for.
82
+ #
83
+ # Measured: paraphrases score 0.44-0.83 on durability against 0.82-0.83 for the same
84
+ # facts stated fresh -- a restatement reads as less durable than the thing it
85
+ # restates, which is the wrong question. The entry it matched is already in
86
+ # memory.md, so its durability was settled when that entry was written; a line
87
+ # matching it is by definition not the one-off observation this gate is for.
88
+ if out['matched']:
89
+ out['injectable'] = True
90
+ return out
91
+
92
+
93
+ # The number of facts a single dispatch is likely to act on. The existing 40-line / 4KB
94
+ # cap stays the outer bound -- this only narrows within it, and never adds a line.
95
+ SLICE_KEEP = 8
96
+ FILE_CHARS = 4000
97
+
98
+
99
+ def still_true(*, text: str, anchor_path, run_dir=None, config: dict | None = None):
100
+ """Has this fact gone false while the file it points at still exists?
101
+
102
+ Path-anchor staleness already drops an entry whose file was deleted. This catches
103
+ the other case: the file is still there and the fact about it stopped being true.
104
+ Returns None when nothing could be judged, which must be read as "keep the entry" --
105
+ dropping a fact on a failed request would quietly erase memory over time.
106
+ """
107
+ from .questions import memory_still_true
108
+ from pathlib import Path
109
+
110
+ try:
111
+ content = Path(anchor_path).read_text(errors='replace')[:FILE_CHARS]
112
+ except OSError:
113
+ return None
114
+ if not (text or '').strip():
115
+ return None
116
+ result = ask(dict(fact=text, file=str(anchor_path)),
117
+ dict(still=memory_still_true(text, content)),
118
+ site='jev-memory-stale', run_dir=run_dir, config=config)
119
+ if result is None:
120
+ return None
121
+ probability = noul(result, 'still')
122
+ if probability is None:
123
+ return None
124
+ # Its own threshold, well below gate_warn. Measured over six facts against real
125
+ # files: ones that had genuinely gone false scored 0.03-0.10, ones still true scored
126
+ # 0.47-0.96. gate_warn's 0.60 sits inside the true range and would have deleted a
127
+ # fact the file still supports. Dropping a fact is irreversible and silent, while
128
+ # keeping a stale one costs a line until someone notices, so the bar sits low enough
129
+ # that only a confident "this went false" acts.
130
+ return dict(probability=probability,
131
+ stale=probability < threshold('stale_drop', config=config))
132
+
133
+
134
+ def relevant_lines(*, task: str, lines: list, keep: int = SLICE_KEEP,
135
+ run_dir=None, config: dict | None = None):
136
+ """The subset of memory worth spending prompt space on for THIS task.
137
+
138
+ Returns None when no judgment could be made, so the caller injects everything it
139
+ would have injected before. Never returns more lines than it was given, and never
140
+ reorders them -- the section structure above these lines is what gives them meaning.
141
+ """
142
+ from .questions import memory_relevant
143
+
144
+ lines = [line for line in (lines or []) if line.strip()]
145
+ if len(lines) <= keep:
146
+ # Nothing to narrow, and asking would cost a request to learn that.
147
+ return None
148
+ questions = {f'l{i}': memory_relevant(line, task) for i, line in enumerate(lines)}
149
+ result = ask(dict(task=task, facts=lines), questions, site='jev-memory-slice',
150
+ run_dir=run_dir, config=config)
151
+ if result is None:
152
+ return None
153
+
154
+ scored = []
155
+ for i, line in enumerate(lines):
156
+ probability = noul(result, f'l{i}')
157
+ if probability is None:
158
+ # An unscored line is kept: absence of a judgment is not evidence against.
159
+ probability = 1.0
160
+ scored.append((probability, i, line))
161
+ scored.sort(key=lambda item: (-item[0], item[1]))
162
+ chosen = {index for _p, index, _line in scored[:keep]}
163
+ return [line for i, line in enumerate(lines) if i in chosen]
@@ -0,0 +1,49 @@
1
+ """The state forge's outcome rubrics (first_pass, retry_pass) are asked on.
2
+
3
+ One assembler for both sides of the measurement: the export rebuilds this state from
4
+ stored events, live forge builds it from a plan dir. Each side hands over evidence it
5
+ has already read; this only caps and arranges it, so the state a model is scored on and
6
+ the state it answers in production cannot drift apart in shape. Pure: no file reads.
7
+
8
+ The caps are the export's (`forge dataset export`), because those are the states
9
+ Clef was measured on: goal and prompt are NOT cut here, unlike score-plan's request.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ FILE_EXCERPT_CHARS = 600
14
+ MAX_EXCERPT_FILES = 6
15
+ MEMORY_CHARS = 4000
16
+ MAX_SIBLINGS = 40
17
+ SIBLING_TITLE_CHARS = 120
18
+ REVIEW_CHARS = 12000
19
+
20
+
21
+ def siblings(tasks, task_id: str) -> list[dict]:
22
+ """The rest of the plan, in plan order: [{id, title, files, deps}] without `task_id`."""
23
+ out = []
24
+ for row in tasks:
25
+ if row.get('id') == task_id:
26
+ continue
27
+ out.append(dict(id=row.get('id'), title=str(row.get('title', ''))[:SIBLING_TITLE_CHARS],
28
+ files=row.get('files', ''), deps=row.get('deps', '')))
29
+ if len(out) >= MAX_SIBLINGS:
30
+ break
31
+ return out
32
+
33
+
34
+ def assemble(*, goal: str, task_id: str, title: str, files: str, deps: str, prompt: str, approach: str,
35
+ plan_tasks: list, file_excerpts: dict, memory: str, dwarf: dict, reviewer: dict, size: dict) -> dict:
36
+ """The first-pass state: the task as planned, the plan around it, who runs and reviews it."""
37
+ # Values pass through as given: coercing a missing title to '' would change the body,
38
+ # and with it the case id, of every exported case that had none.
39
+ return dict(goal=goal, task=dict(id=task_id, title=title, files=files, deps=deps),
40
+ prompt=prompt, approach=approach, plan_tasks=plan_tasks,
41
+ file_excerpts=file_excerpts, memory=(memory or '')[:MEMORY_CHARS],
42
+ dwarf=dwarf, reviewer=reviewer, size=size)
43
+
44
+
45
+ def retry(state: dict, *, attempt: int, previous_review: str) -> dict:
46
+ """The retry-pass state: the first-pass state plus which try this is and what it must fix."""
47
+ review = previous_review or ''
48
+ return dict(state, attempt=attempt, previous_review=review[:REVIEW_CHARS],
49
+ previous_review_truncated=len(review) > REVIEW_CHARS)