@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,575 @@
1
+ #!/usr/bin/env bash
2
+ # forge-memory.sh — project memory that outlives a single forge run.
3
+ #
4
+ # Every forge dispatch is a clean slate, so without this the tenth run in a repo
5
+ # rediscovers what the first one learned. Two files, deliberately split:
6
+ #
7
+ # .forge/memory.md curated, hard-capped, injected into every prompt
8
+ # .forge/ledger.tsv append-only, one row per dispatch, NEVER injected
9
+ #
10
+ # That split is what makes "remembers everything" and "small" compatible. The
11
+ # ledger is unbounded but costs no context; only memory.md is paid for, on every
12
+ # dispatch of every future run, which is why it has a hard cap.
13
+ #
14
+ # Writing is automatic, so the judgment has to be free and the bookkeeping has to
15
+ # be deterministic: the model that just did the work emits its learning in-band
16
+ # (a FORGE_LEARNING line in its final message, alongside FORGE_VERDICT), and this
17
+ # script does dedup, recurrence counting, staleness and capping in bash. No model
18
+ # ever decides what to prune.
19
+ #
20
+ # Usage:
21
+ # forge-memory.sh inject <repo> <planner|dwarf|qa>
22
+ # forge-memory.sh note <planner|dwarf|qa>
23
+ # forge-memory.sh record <repo> --last <file> --role <r> [--run-id X] [--task T]
24
+ # [--model M] [--verdict V] [--duration S]
25
+ # forge-memory.sh show <repo>
26
+ # forge-memory.sh prune <repo>
27
+ # forge-memory.sh spend <repo> [--run <run-id>]
28
+ #
29
+ # Exit codes: 0 ok | 2 usage error
30
+ set -uo pipefail
31
+
32
+ # Where to log the Jev calls this script makes. Empty means nowhere, which is what it
33
+ # meant everywhere until a real run showed 51 logged calls and an unknown number of
34
+ # unlogged memory ones sitting beside them.
35
+ JEV_RUN_DIR=""
36
+
37
+ die() { printf 'forge: %s\n' "$1" >&2; exit "${2:-2}"; }
38
+
39
+ MAX_LINES="${FORGE_MEMORY_MAX_LINES:-40}"
40
+ MAX_BYTES="${FORGE_MEMORY_MAX_BYTES:-4096}"
41
+ TAB="$(printf '\t')"
42
+
43
+ # Derived here rather than inherited: forge-parallel.sh sets SKILL_DIR but does not
44
+ # export it, and this script is also run directly. Getting it wrong would leave the Jev
45
+ # curation below silently disabled instead of visibly broken.
46
+ SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
47
+
48
+ # Writing files into someone's repo automatically needs an escape hatch that does
49
+ # not require editing the skill.
50
+ memory_off() { [ "${FORGE_MEMORY:-on}" = "off" ]; }
51
+
52
+ # --- run dataset ----------------------------------------------------------------
53
+ # What curation decided, for the run dataset (scripts/forge_dataset): the final ledger
54
+ # rows say what was kept, but not that Jev refiled a category, merged a paraphrase or
55
+ # muted a fact, nor which facts a rebuild dropped and why. Fail-open and silent.
56
+ MEMORY_DROPS="" # when set, rebuild appends "<reason>\t<category>\t<text>" rows here
57
+ memory_dataset_on() {
58
+ case "${FORGE_DATASET:-on}" in off|OFF|Off|0|no|false) return 1 ;; esac
59
+ return 0
60
+ }
61
+ memory_dataset_event() { # memory_dataset_event <name> <repo> <run-dir> [--set k=v | --file label=path]...
62
+ local name="$1" repo="$2" dir="$3"; shift 3
63
+ memory_dataset_on || return 0
64
+ python3 "$SKILL_DIR/scripts/forge-dataset.py" event "$name" --repo "$repo" ${dir:+--dir "$dir"} "$@" \
65
+ >/dev/null 2>&1 || true
66
+ }
67
+
68
+ dir_for() { echo "$1/.forge"; }
69
+ memory_for() { echo "$1/.forge/memory.md"; }
70
+ ledger_for() { echo "$1/.forge/ledger.tsv"; }
71
+
72
+ # Normalized dedup key: lowercase, every run of non-alphanumerics collapsed to a
73
+ # single space. Exact match after that — genuinely different phrasings will NOT
74
+ # merge. That is mitigated by a feedback loop rather than by clever matching:
75
+ # memory is in the prompt, so a reviewer that has seen "2x: route added without
76
+ # updating openapi.yaml" tends to re-emit that phrasing instead of inventing one.
77
+ norm_key() {
78
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
79
+ | sed 's/[^a-z0-9]\{1,\}/ /g; s/^ //; s/ $//'
80
+ }
81
+
82
+ # A trailing [path] is what makes an entry's staleness detectable later.
83
+ anchor_of() {
84
+ printf '%s' "$1" | sed -n 's/.*\[\([^][]\{1,\}\)\][[:space:]]*$/\1/p'
85
+ }
86
+ strip_anchor() {
87
+ printf '%s' "$1" | sed 's/[[:space:]]*\[[^][]\{1,\}\][[:space:]]*$//'
88
+ }
89
+
90
+ # Tabs are the ledger's field separator and newlines are its record separator, so
91
+ # neither may survive into a field.
92
+ sanitize() { printf '%s' "$1" | tr '\t\n' ' ' | sed 's/ \{1,\}/ /g; s/^ //; s/ $//'; }
93
+
94
+ # One discursive model should not be able to spend the whole byte budget on a
95
+ # single entry. Truncate the prose but keep the [anchor], since that is what lets
96
+ # the entry be recognised as stale later.
97
+ MAX_FACT_CHARS="${FORGE_MEMORY_MAX_FACT_CHARS:-180}"
98
+ clamp_fact() {
99
+ local text="$1" anchor body
100
+ [ "${#text}" -le "$MAX_FACT_CHARS" ] && { printf '%s' "$text"; return; }
101
+ anchor="$(anchor_of "$text")"
102
+ body="$(strip_anchor "$text")"
103
+ local room="$MAX_FACT_CHARS"
104
+ [ -n "$anchor" ] && room=$((MAX_FACT_CHARS - ${#anchor} - 3))
105
+ [ "$room" -lt 40 ] && room=40
106
+ body="$(printf '%s' "$body" | cut -c1-"$room" | sed 's/[^ ]*$//; s/ $//')"
107
+ if [ -n "$anchor" ]; then printf '%s… [%s]' "$body" "$anchor"; else printf '%s…' "$body"; fi
108
+ }
109
+
110
+ section_title() {
111
+ case "$1" in
112
+ verify) echo "## Verify" ;;
113
+ trap) echo "## Known traps" ;;
114
+ finding) echo "## Recurring QA findings" ;;
115
+ esac
116
+ }
117
+
118
+ # --- rebuild ------------------------------------------------------------------
119
+ # memory.md is regenerated from the whole ledger rather than mutated in place.
120
+ # Rebuilding is idempotent and order-independent, so a crashed or concurrent run
121
+ # cannot leave memory half-updated — it can only leave the ledger short a row.
122
+ rebuild() { # rebuild <repo> -> prints "kept<TAB>pruned_stale<TAB>pruned_cap"
123
+ local repo="$1" led mem tmp cand jev_loaded=0
124
+ if [ "${FORGE_MEMORY_DEEP_PRUNE:-0}" = 1 ] && [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
125
+ source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
126
+ fi
127
+ led="$(ledger_for "$repo")"; mem="$(memory_for "$repo")"
128
+ [ -f "$led" ] || return 0
129
+ tmp="$mem.tmp.$$"; cand="$mem.cand.$$"
130
+
131
+ # Group by (category, key). count = number of DISTINCT runs the key appeared in,
132
+ # which is what makes a recurrence threshold meaningful: one run emitting the
133
+ # same learning twice is still one occurrence.
134
+ awk -F'\t' '
135
+ /^#/ || NF < 9 { next }
136
+ # Column 11 is the injectable flag. Rows written before it existed have NF == 10
137
+ # and are treated as injectable, so an existing ledger keeps behaving as it did.
138
+ NF >= 11 && $11 == "0" { next }
139
+ {
140
+ ts=$1; run=$2; cat=$7; key=$8; txt=$9
141
+ k = cat "\034" key
142
+ if (!((k "\034" run) in seen_run)) { seen_run[k "\034" run]=1; cnt[k]++ }
143
+ if (ts >= lastts[k]) { lastts[k]=ts; last[k]=txt }
144
+ catof[k]=cat
145
+ }
146
+ END { for (k in cnt) printf "%s\t%d\t%s\t%s\n", catof[k], cnt[k], lastts[k], last[k] }
147
+ ' "$led" > "$cand" 2>/dev/null
148
+
149
+ local kept=0 stale=0 capped=0
150
+ : > "$tmp"
151
+ local sec cat count ts text anchor line
152
+ for sec in verify trap finding; do
153
+ local body="$mem.sec.$$"; : > "$body"
154
+ # Highest count first, then most recent — the ordering a human scans for a
155
+ # systemic problem, and deterministic so tests can assert on it.
156
+ while IFS=$'\t' read -r cat count ts text; do
157
+ [ "$cat" = "$sec" ] || continue
158
+ # A finding seen once is an incident, not a pattern. Promoting it would fill
159
+ # memory with one-offs, which is exactly how this becomes a junk drawer.
160
+ if [ "$sec" = finding ] && [ "$count" -lt 2 ]; then continue; fi
161
+ anchor="$(anchor_of "$text")"
162
+ # An entry anchored to a path that no longer exists is stale by construction:
163
+ # the code it describes is gone, so the fact is at best useless and at worst
164
+ # actively misleading. Drop it rather than wait for the cap to evict it.
165
+ if [ -n "$anchor" ] && [ ! -e "$repo/$anchor" ]; then
166
+ [ -z "$MEMORY_DROPS" ] || printf 'anchor_missing\t%s\t%s\n' "$cat" "$text" >> "$MEMORY_DROPS"
167
+ stale=$((stale+1)); continue
168
+ fi
169
+ # The anchor still exists, but the fact about it may have quietly stopped being
170
+ # true. Only on an explicit `prune`: rebuild runs after EVERY dispatch, and a
171
+ # request per entry per dispatch would be the most expensive thing in a run.
172
+ # An unreachable or undecided answer keeps the entry -- dropping a fact on a
173
+ # failed request would erode memory over time with nothing recording why.
174
+ if [ "${FORGE_MEMORY_DEEP_PRUNE:-0}" = 1 ] && [ -n "$anchor" ] \
175
+ && [ "$jev_loaded" = 1 ] && forge_jev_active memory; then
176
+ if python3 "$SKILL_DIR/scripts/forge-jev.py" memory-stale \
177
+ --text "$text" --anchor "$repo/$anchor" \
178
+ ${JEV_RUN_DIR:+--run-dir "$JEV_RUN_DIR"} >/dev/null 2>&1; then
179
+ [ -z "$MEMORY_DROPS" ] || printf 'jev_stale\t%s\t%s\n' "$cat" "$text" >> "$MEMORY_DROPS"
180
+ stale=$((stale+1)); continue
181
+ fi
182
+ fi
183
+ if [ "$sec" = finding ]; then
184
+ printf '%s\t- %sx: %s\n' "$count" "$count" "$text" >> "$body"
185
+ else
186
+ printf '%s\t- %s\n' "$count" "$text" >> "$body"
187
+ fi
188
+ done < <(sort -t"$TAB" -k2,2nr -k3,3r "$cand")
189
+
190
+ if [ -s "$body" ]; then
191
+ { section_title "$sec"; cut -f2- "$body"; echo; } >> "$tmp"
192
+ kept=$((kept + $(wc -l < "$body" | tr -d ' ')))
193
+ fi
194
+ rm -f "$body"
195
+ done
196
+ rm -f "$cand"
197
+
198
+ if [ ! -s "$tmp" ]; then rm -f "$tmp" "$mem"; printf '0\t%s\t0\n' "$stale"; return 0; fi
199
+
200
+ local header="$mem.hdr.$$"
201
+ {
202
+ echo "# forge project memory"
203
+ echo "<!-- Maintained by forge from what its own runs observed. Do not edit this file"
204
+ echo " inside a dwarf run: it is excluded from every diff and your edit would be"
205
+ echo " overwritten. Facts only, one per line. -->"
206
+ echo
207
+ cat "$tmp"
208
+ } > "$header"
209
+
210
+ # Cap enforcement. Everything above already dropped stale entries and unpromoted
211
+ # findings, so what is left is real; trimming happens from the bottom, which the
212
+ # sort above has arranged to be the lowest-count, least-recent facts.
213
+ while [ "$(wc -l < "$header" | tr -d ' ')" -gt "$MAX_LINES" ] || \
214
+ [ "$(wc -c < "$header" | tr -d ' ')" -gt "$MAX_BYTES" ]; do
215
+ local last_fact
216
+ last_fact="$(grep -n '^- ' "$header" | tail -1 | cut -d: -f1)"
217
+ [ -n "$last_fact" ] || break
218
+ [ -z "$MEMORY_DROPS" ] || printf 'cap\t-\t%s\n' "$(sed -n "${last_fact}p" "$header")" >> "$MEMORY_DROPS"
219
+ sed "${last_fact}d" "$header" > "$header.x" && mv "$header.x" "$header"
220
+ capped=$((capped+1)); kept=$((kept-1))
221
+ done
222
+ # The cap can eat a section's last fact, leaving a heading over nothing.
223
+ drop_empty_sections "$header"
224
+
225
+ mkdir -p "$(dir_for "$repo")"
226
+ mv "$header" "$mem"
227
+ rm -f "$tmp"
228
+ printf '%s\t%s\t%s\n' "$kept" "$stale" "$capped"
229
+ }
230
+
231
+ # Remove a "## Section" line that has no "- " fact under it, which the cap can
232
+ # produce by trimming a section's last entry.
233
+ drop_empty_sections() {
234
+ local f="$1" out="$1.de.$$"
235
+ awk '
236
+ { l[NR] = $0 }
237
+ END {
238
+ for (i = 1; i <= NR; i++) {
239
+ if (l[i] ~ /^## /) {
240
+ keep = 0
241
+ for (j = i + 1; j <= NR && l[j] !~ /^## /; j++)
242
+ if (l[j] ~ /^- /) { keep = 1; break }
243
+ if (!keep) continue
244
+ }
245
+ print l[i]
246
+ }
247
+ }
248
+ ' "$f" > "$out"
249
+ # Collapse any run of blank lines to a single one.
250
+ awk '{ if ($0 == "") blank++; else blank = 0; if (blank < 2) print }' "$out" > "$f"
251
+ rm -f "$out"
252
+ }
253
+
254
+ # --- inject -------------------------------------------------------------------
255
+ # Slices differ by role because every injected line is paid for on every dispatch:
256
+ # QA is not building anything, so the build command is dead weight in its prompt.
257
+ do_inject() {
258
+ local repo="${1:?inject needs a repo}" role="${2:?inject needs a role}"
259
+ shift 2 2>/dev/null || true
260
+ local TASKFILE=""
261
+ while [ $# -gt 0 ]; do
262
+ case "$1" in
263
+ --task) TASKFILE="${2:-}"; shift 2 ;;
264
+ --run-dir) JEV_RUN_DIR="${2:-}"; shift 2 ;;
265
+ *) shift ;;
266
+ esac
267
+ done
268
+ memory_off && return 0
269
+ local mem; mem="$(memory_for "$repo")"
270
+ [ -s "$mem" ] || return 0
271
+
272
+ local want
273
+ case "$role" in
274
+ planner|dwarf) want="Verify|Known traps|Recurring QA findings" ;;
275
+ qa) want="Known traps|Recurring QA findings" ;;
276
+ *) die "inject: unknown role '$role' (planner|dwarf|qa)" ;;
277
+ esac
278
+
279
+ local body; body="$(awk -v want="$want" '
280
+ /^## / { keep = 0; t=$0; sub(/^## /,"",t)
281
+ n=split(want,w,"|"); for (i=1;i<=n;i++) if (w[i]==t) keep=1
282
+ if (keep) { print ""; print $0 } ; next }
283
+ /^# |^<!--|^ / { next }
284
+ keep && NF { print }
285
+ ' "$mem")"
286
+ [ -n "$(printf '%s' "$body" | tr -d '[:space:]')" ] || return 0
287
+
288
+ # Narrow to the facts that bear on THIS task. The role slice and the 40-line/4KB cap
289
+ # above remain the outer bound; this only removes lines, never adds or reorders them,
290
+ # and any failure leaves `body` exactly as it was. Every line here is paid for on
291
+ # every dispatch of every run, which is what makes narrowing worth a request -- and
292
+ # also why the fallback has to be the full slice rather than an empty one.
293
+ if [ -n "$TASKFILE" ] && [ -r "$TASKFILE" ]; then
294
+ local jev_loaded=0
295
+ if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
296
+ source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
297
+ fi
298
+ if [ "$jev_loaded" = 1 ] && forge_jev_active memory; then
299
+ local facts sliced
300
+ facts="$(printf '%s\n' "$body" | grep '^- ' || true)"
301
+ if [ -n "$facts" ]; then
302
+ local ftmp="${TMPDIR:-/tmp}/forge-mem-$$.txt"
303
+ printf '%s\n' "$facts" > "$ftmp"
304
+ local ktmp="${TMPDIR:-/tmp}/forge-mem-keep-$$.txt"
305
+ if python3 "$SKILL_DIR/scripts/forge-jev.py" memory-slice \
306
+ --task "$TASKFILE" --lines "$ftmp" \
307
+ ${JEV_RUN_DIR:+--run-dir "$JEV_RUN_DIR"} > "$ktmp" 2>/dev/null && [ -s "$ktmp" ]; then
308
+ # Two files, not -v: awk's -v processes escapes and cannot carry a literal
309
+ # newline, so passing the kept lines that way silently truncated the set at the
310
+ # first one. Read them as a first file instead.
311
+ sliced="$(printf '%s\n' "$body" | awk '
312
+ NR == FNR { want[$0] = 1; next }
313
+ /^## / { header = $0; printed = 0; next }
314
+ /^- / { if ($0 in want) { if (!printed) { print ""; print header; printed = 1 } print $0 } next }
315
+ { print }
316
+ ' "$ktmp" -)"
317
+ # Only adopt a narrowed body that actually kept something. An awk that fails,
318
+ # or a slice that matched nothing, must leave the full slice in place -- an
319
+ # empty memory section is a worse outcome than an unnarrowed one.
320
+ if [ -n "$(printf '%s' "$sliced" | grep -c '^- ' | tr -d ' ')" ] \
321
+ && printf '%s' "$sliced" | grep -q '^- '; then
322
+ body="$sliced"
323
+ fi
324
+ fi
325
+ rm -f "$ftmp" "$ktmp" 2>/dev/null || true
326
+ fi
327
+ fi
328
+ fi
329
+
330
+ echo "## What forge learned in this repo before"
331
+ if [ "$role" = qa ]; then
332
+ echo "From earlier forge runs on this project. Use it to notice a repeat bug and to"
333
+ echo "avoid reporting a known-generated file as a defect — it is context, not a checklist."
334
+ else
335
+ echo "From earlier forge runs on this project. Treat it as known-good context so you do"
336
+ echo "not rediscover it; if something here is now wrong, say so in your final message."
337
+ fi
338
+ printf '%s\n' "$body"
339
+ }
340
+
341
+ # --- note ---------------------------------------------------------------------
342
+ # The instruction that makes automatic memory possible at all. It asks for the
343
+ # learning in the same breath as the work, from the model that just did it — so
344
+ # extraction costs no extra dispatch and no separate model has to guess what
345
+ # mattered. Deliberately asks for nothing when nothing generalizes: a role that
346
+ # invents a learning to be helpful is precisely how this fills up with noise.
347
+ do_note() {
348
+ local role="${1:?note needs a role}"
349
+ memory_off && return 0
350
+ echo "## Before you finish"
351
+ echo "If — and only if — you learned something about THIS repository that would still be"
352
+ if [ "$role" = qa ]; then
353
+ # The reviewer's verdict line must stay the very last line of the reply, so a learning
354
+ # goes just above it; asking for "the very end" produced replies the parser rejected.
355
+ echo "true and useful on an unrelated task next month, add a line for it just above your"
356
+ echo "final FORGE_VERDICT line (the verdict stays the last line of your reply):"
357
+ else
358
+ echo "true and useful on an unrelated task next month, add a line for it at the very end:"
359
+ fi
360
+ echo
361
+ echo " FORGE_LEARNING: verify | <the command that actually builds or tests this project>"
362
+ echo " FORGE_LEARNING: trap | <a landmine: a flaky test, a generated file, a sharp edge>"
363
+ if [ "$role" = qa ]; then
364
+ echo " FORGE_LEARNING: finding | <a mistake pattern likely to recur, not this one bug>"
365
+ fi
366
+ echo
367
+ echo "End the text with [path/that/anchors/it] where a path makes sense; forge drops an"
368
+ echo "entry whose path stops existing, which is how this file stays true as code moves."
369
+ echo "One short line each, phrased as a fact. Reuse the exact wording of anything already"
370
+ echo "listed above rather than rephrasing it, so repeats are recognised as repeats."
371
+ echo "Most runs teach nothing durable. Emitting no line is the normal, correct outcome."
372
+ }
373
+
374
+ # --- record -------------------------------------------------------------------
375
+ do_record() {
376
+ local repo="${1:?record needs a repo}"; shift
377
+ local LAST="" ROLE="" RUN_ID="-" TASK="-" MODEL="-" VERDICT="-" DURATION="-"
378
+ while [ $# -gt 0 ]; do
379
+ case "$1" in
380
+ --last) LAST="${2:?--last needs a value}"; shift 2 ;;
381
+ --role) ROLE="${2:?--role needs a value}"; shift 2 ;;
382
+ --run-id) RUN_ID="${2:?--run-id needs a value}"; shift 2 ;;
383
+ --task) TASK="${2:?--task needs a value}"; shift 2 ;;
384
+ --run-dir) JEV_RUN_DIR="${2:?--run-dir needs a value}"; shift 2 ;;
385
+ --model) MODEL="${2:?--model needs a value}"; shift 2 ;;
386
+ --verdict) VERDICT="${2:?--verdict needs a value}"; shift 2 ;;
387
+ --duration) DURATION="${2:?--duration needs a value}"; shift 2 ;;
388
+ *) die "record: unknown option '$1'" ;;
389
+ esac
390
+ done
391
+ memory_off && return 0
392
+ [ -n "$ROLE" ] || die "record needs --role"
393
+ [ -d "$repo" ] || die "record: '$repo' is not a directory"
394
+
395
+ local led ts n=0
396
+ led="$(ledger_for "$repo")"
397
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
398
+ mkdir -p "$(dir_for "$repo")"
399
+ if [ ! -f "$led" ]; then
400
+ printf '# ts\trun_id\ttask\trole\tmodel\tverdict\tcategory\tkey\ttext\tduration_s\tinjectable\n' > "$led"
401
+ fi
402
+
403
+ local jev_loaded=0 cur=""
404
+ if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
405
+ source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
406
+ fi
407
+ # Scratch files for the dataset event, outside the repo: .forge/ is the user's tree.
408
+ if memory_dataset_on; then
409
+ cur="$(mktemp "${TMPDIR:-/tmp}/forge-memory-cur.XXXXXX" 2>/dev/null)" || cur=""
410
+ MEMORY_DROPS="$(mktemp "${TMPDIR:-/tmp}/forge-memory-drops.XXXXXX" 2>/dev/null)" || MEMORY_DROPS=""
411
+ fi
412
+ if [ -n "$LAST" ] && [ -r "$LAST" ]; then
413
+ local line cat text key inject curated curated_rc jcat jinject jmatch jkey ecat
414
+ while IFS= read -r line; do
415
+ cat="$(printf '%s' "$line" | sed -n 's/^[[:space:]]*FORGE_LEARNING:[[:space:]]*\([a-zA-Z]\{1,\}\)[[:space:]]*|.*/\1/p' | tr '[:upper:]' '[:lower:]')"
416
+ text="$(printf '%s' "$line" | sed -n 's/^[[:space:]]*FORGE_LEARNING:[[:space:]]*[a-zA-Z]\{1,\}[[:space:]]*|[[:space:]]*\(.*\)/\1/p')"
417
+ case "$cat" in verify|trap|finding) ;; *) continue ;; esac
418
+ text="$(clamp_fact "$(sanitize "$text")")"
419
+ [ -n "$text" ] || continue
420
+ key="$(norm_key "$(strip_anchor "$text")")"
421
+ [ -n "$key" ] || continue
422
+ # Jev may refile the category, merge a paraphrase onto an existing key, and mark
423
+ # a one-off as not worth injecting. Every one of those is a suggestion: any
424
+ # failure, any missing key, any disabled capability leaves the three values below
425
+ # exactly as the model emitted them. The row is written either way -- the ledger
426
+ # is append-only and never injected, so this gates injection, never recording.
427
+ inject=1; ecat="$cat"; jcat=""; jinject=""; jmatch=""
428
+ if [ "$jev_loaded" = 1 ] && forge_jev_active memory; then
429
+ curated="$(python3 "$SKILL_DIR/scripts/forge-jev.py" memory-curate \
430
+ --repo "$repo" --category "$cat" --text "$text" \
431
+ ${JEV_RUN_DIR:+--run-dir "$JEV_RUN_DIR"} 2>/dev/null)"
432
+ curated_rc=$?
433
+ if [ "$curated_rc" -eq 0 ] && [ -n "$curated" ]; then
434
+ jcat="$(printf '%s' "$curated" | cut -f1)"
435
+ jinject="$(printf '%s' "$curated" | cut -f2)"
436
+ jmatch="$(printf '%s' "$curated" | cut -f3)"
437
+ case "$jcat" in verify|trap|finding) cat="$jcat" ;; esac
438
+ [ "$jinject" = 0 ] && inject=0
439
+ if [ -n "$jmatch" ]; then
440
+ # Re-derive the key with THIS file's norm_key rather than having Python
441
+ # return one: two implementations of the same normalisation would drift,
442
+ # and a key that disagrees by one character silently stops deduplicating.
443
+ # The leading "Nx: " is rebuild's recurrence marker, not part of the fact.
444
+ jmatch="$(printf '%s' "$jmatch" | sed 's/^[0-9]\{1,\}x: //')"
445
+ jkey="$(norm_key "$(strip_anchor "$jmatch")")"
446
+ [ -n "$jkey" ] && key="$jkey"
447
+ fi
448
+ fi
449
+ fi
450
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
451
+ "$ts" "$(sanitize "$RUN_ID")" "$(sanitize "$TASK")" "$(sanitize "$ROLE")" \
452
+ "$(sanitize "$MODEL")" "$(sanitize "$VERDICT")" "$cat" "$key" "$text" \
453
+ "$(sanitize "$DURATION")" "$inject" >> "$led"
454
+ [ -z "$cur" ] || printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$ecat" "$cat" "$key" "$inject" \
455
+ "$jcat" "$jinject" "$jmatch" "$text" >> "$cur"
456
+ n=$((n+1))
457
+ done < <(grep -o 'FORGE_LEARNING:.*' "$LAST" 2>/dev/null)
458
+ fi
459
+
460
+ # A dispatch that emitted no learning is still worth a ledger row: it is how the
461
+ # ledger can later answer "which model has worked on this repo, and how did it do".
462
+ if [ "$n" -eq 0 ]; then
463
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
464
+ "$ts" "$(sanitize "$RUN_ID")" "$(sanitize "$TASK")" "$(sanitize "$ROLE")" \
465
+ "$(sanitize "$MODEL")" "$(sanitize "$VERDICT")" "-" "-" "-" \
466
+ "$(sanitize "$DURATION")" >> "$led"
467
+ fi
468
+
469
+ local out kept stale capped
470
+ out="$(rebuild "$repo")"
471
+ kept="$(printf '%s' "$out" | cut -f1)"; stale="$(printf '%s' "$out" | cut -f2)"
472
+ capped="$(printf '%s' "$out" | cut -f3)"
473
+ memory_dataset_event memory_record "$repo" "$JEV_RUN_DIR" --set role="$ROLE" --set run_id="$RUN_ID" \
474
+ --set task="$TASK" --set model="$MODEL" --set verdict="$VERDICT" --set recorded="$n" \
475
+ --set kept="${kept:-0}" --set stale="${stale:-0}" --set capped="${capped:-0}" \
476
+ --set max_lines="$MAX_LINES" --set max_bytes="$MAX_BYTES" \
477
+ ${cur:+--file curation="$cur"} ${MEMORY_DROPS:+--file drops="$MEMORY_DROPS"} --file memory="$(memory_for "$repo")"
478
+ rm -f ${cur:+"$cur"} ${MEMORY_DROPS:+"$MEMORY_DROPS"}
479
+ printf 'memory: %s learning(s) recorded, %s fact(s) live' "$n" "${kept:-0}"
480
+ [ "${stale:-0}" -gt 0 ] 2>/dev/null && printf ', %s stale dropped' "$stale"
481
+ [ "${capped:-0}" -gt 0 ] 2>/dev/null && printf ', %s trimmed at cap' "$capped"
482
+ printf '\n'
483
+ }
484
+
485
+ # --- prune --------------------------------------------------------------------
486
+ do_prune() { # do_prune <repo>
487
+ local repo="$1" out
488
+ if memory_dataset_on; then
489
+ MEMORY_DROPS="$(mktemp "${TMPDIR:-/tmp}/forge-memory-drops.XXXXXX" 2>/dev/null)" || MEMORY_DROPS=""
490
+ fi
491
+ out="$(FORGE_MEMORY_DEEP_PRUNE=1 rebuild "$repo")"
492
+ memory_dataset_event memory_prune "$repo" "" --set kept="$(printf '%s' "$out" | cut -f1)" \
493
+ --set stale="$(printf '%s' "$out" | cut -f2)" --set capped="$(printf '%s' "$out" | cut -f3)" \
494
+ ${MEMORY_DROPS:+--file drops="$MEMORY_DROPS"} --file memory="$(memory_for "$repo")"
495
+ rm -f ${MEMORY_DROPS:+"$MEMORY_DROPS"}
496
+ }
497
+
498
+ # --- spend --------------------------------------------------------------------
499
+ # The ledger already carries one row per dispatch, and it is never injected into a
500
+ # prompt, so accounting from it costs nothing at all. Time and dispatch counts
501
+ # only: token accounting is exposed differently (or not at all) by the five CLIs,
502
+ # and a number forge cannot actually measure would be worse than no number.
503
+ do_spend() {
504
+ local repo="${1:?spend needs a repo}"; shift
505
+ local ONLY_RUN=""
506
+ while [ $# -gt 0 ]; do
507
+ case "$1" in
508
+ --run) ONLY_RUN="${2:?--run needs a value}"; shift 2 ;;
509
+ *) die "spend: unknown option '$1'" ;;
510
+ esac
511
+ done
512
+ local led; led="$(ledger_for "$repo")"
513
+ [ -s "$led" ] || { echo "no ledger in $repo/.forge — nothing dispatched here yet"; return 0; }
514
+
515
+ awk -F'\t' -v only="$ONLY_RUN" '
516
+ function hms(x, h,m,s) {
517
+ if (x == "") return " -"
518
+ h=int(x/3600); m=int((x%3600)/60); s=x%60
519
+ if (h>0) return sprintf("%dh%02dm", h, m)
520
+ if (m>0) return sprintf("%dm%02ds", m, s)
521
+ return sprintf("%ds", s)
522
+ }
523
+ /^#/ || NF < 9 { next }
524
+ {
525
+ ts=$1; run=$2; task=$3; role=$4; model=$5
526
+ dur = (NF >= 10 ? $10 : "-")
527
+ if (only != "" && run != only) next
528
+ # One dispatch emitting two learnings writes two rows. Every row from one
529
+ # dispatch shares its timestamp, so this counts dispatches, not lines.
530
+ k = ts "\034" run "\034" task "\034" role
531
+ if (!(k in seen)) {
532
+ seen[k] = 1; n[model]++; rn[role]++; total++; runs[run] = 1
533
+ }
534
+ # Count the dispatch once, but take its duration from whichever of its rows
535
+ # carries one — a dispatch that straddles the schema change has both kinds.
536
+ if (dur ~ /^[0-9]+$/ && !(k in durof)) {
537
+ durof[k] = dur
538
+ t[model] += dur; timed[model]++
539
+ rt[role] += dur
540
+ tt += dur; ttn++
541
+ }
542
+ }
543
+ END {
544
+ if (total == 0) { print "no dispatches recorded" ; exit }
545
+ nruns = 0; for (r in runs) nruns++
546
+ printf "%d dispatch(es) across %d run(s), %s of model time\n", total, nruns, hms(tt)
547
+ if (ttn < total)
548
+ printf "(%d dispatch(es) predate duration recording and are counted but not timed)\n", total - ttn
549
+ print "Durations sum concurrent work, so they exceed the wall-clock of a parallel run."
550
+ print ""
551
+ printf " %-26s %6s %9s %9s\n", "by model", "disp", "total", "mean"
552
+ for (m in n)
553
+ printf " %-26s %6d %9s %9s\n", m, n[m], hms(t[m]), \
554
+ (timed[m] > 0 ? hms(int(t[m]/timed[m])) : " -")
555
+ print ""
556
+ printf " %-26s %6s %9s\n", "by role", "disp", "total"
557
+ for (r in rn)
558
+ printf " %-26s %6d %9s\n", r, rn[r], hms(rt[r])
559
+ }
560
+ ' "$led"
561
+ }
562
+
563
+ # --- main ---------------------------------------------------------------------
564
+ [ $# -ge 1 ] || die "usage: forge-memory.sh <inject|note|record|show|prune|spend> [repo] [...]"
565
+ CMD="$1"; shift
566
+ case "$CMD" in
567
+ inject) do_inject "$@" ;;
568
+ note) do_note "$@" ;;
569
+ record) do_record "$@" ;;
570
+ show) mem="$(memory_for "${1:?show needs a repo}")"; [ -s "$mem" ] && cat "$mem" ;;
571
+ spend) do_spend "$@" ;;
572
+ prune) do_prune "${1:?prune needs a repo}" >/dev/null
573
+ do_inject "${1}" dwarf ;;
574
+ *) die "unknown subcommand '$CMD'" ;;
575
+ esac
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Each runner owns its metric state in a process/subshell, including failure exits.
3
+ forge_metric_begin() {
4
+ FORGE_METRIC_ROOT="$1"; FORGE_METRIC_ROLE="$2"
5
+ FORGE_METRIC="$(python3 "$SKILL_DIR/scripts/forge-runtime.py" begin "$1" "$2")" || return 3
6
+ # Dispatches started from here name this execution as their parent in the run dataset.
7
+ export FORGE_DATASET_PARENT="${FORGE_METRIC##*/}"
8
+ # The hook, when the caller defines it, records status before finish and Fractal's _outcome.
9
+ trap 'metric_rc=$?; ! declare -F forge_metric_on_exit >/dev/null || forge_metric_on_exit "$metric_rc"; python3 "$SKILL_DIR/scripts/forge-runtime.py" finish "$FORGE_METRIC" "$FORGE_METRIC_ROOT" "$FORGE_METRIC_ROLE" pipeline "$metric_rc"; if [ -n "${FORGE_FRACTAL_RUN:-}" ]; then python3 "$SKILL_DIR/scripts/forge-fractal.py" _outcome "$FORGE_METRIC_ROOT" "$metric_rc"; fi; exit "$metric_rc"' EXIT
10
+ }
11
+ forge_metric_phase() {
12
+ python3 "$SKILL_DIR/scripts/forge-runtime.py" phase "$FORGE_METRIC" "$1"
13
+ }
14
+
15
+ # --- run dataset (scripts/forge_dataset) --------------------------------------------
16
+ # Facts that are gone before any EXIT trap could read them: an aborted merge, a combine
17
+ # that rolled back, a branch that moved. Fail-open and silent, like every capture.
18
+ forge_dataset_off() {
19
+ case "${FORGE_DATASET:-on}" in off|OFF|Off|0|no|false) return 0 ;; esac
20
+ return 1
21
+ }
22
+ forge_dataset_event() { # forge_dataset_event <name> --dir <dir> [--task id] [--set k=v]... [--file label=path]...
23
+ forge_dataset_off && return 0
24
+ python3 "$SKILL_DIR/scripts/forge-dataset.py" event "$@" >/dev/null 2>&1 || true
25
+ }
26
+ forge_dataset_invocation() { # forge_dataset_invocation <command> <rc> <started> [argv...]
27
+ forge_dataset_off && return 0
28
+ local command="$1" rc="$2" started="$3"; shift 3
29
+ # Options before the command name: everything after it is the subcommand's own argv.
30
+ python3 "$SKILL_DIR/scripts/forge-dataset.py" invocation --dir "${1:-}" --rc "$rc" \
31
+ --started "$started" --pid "$$" "$command" -- "$@" >/dev/null 2>&1 || true
32
+ }
33
+
34
+ # --- update notice (scripts/forge-version.py) ----------------------------------------
35
+ # One line on stderr when a newer forge release exists, checked at most once a day. Silent
36
+ # offline, in CI and with FORGE_UPDATE_CHECK=off; it never changes a run's exit code.
37
+ forge_update_notice() {
38
+ python3 "$SKILL_DIR/scripts/forge-version.py" notice --source "$SKILL_DIR" </dev/null >&2 2>/dev/null || true
39
+ }