Skip to content
This repository was archived by the owner on Sep 8, 2026. It is now read-only.

Commit ea363b8

Browse files
authored
Add trace artifact v0 for bean-run (#2)
bean-run emits a stable post-run artifact to .bean/runs/<run_id>.json (one file per run) so future tooling can analyze a corpus of runs without scraping transcripts. - Fixed top-level shape (additionalProperties:false) with a documented `metadata` extension hatch. - Fails closed: a trace-write failure exits 3 (infra error), never a false clean run. - schemas/trace.schema.json + skills/bean/references/trace.md + conformance shape assertion. - Ignores emitted runtime traces (**/.bean/runs/, **/.bean/verdicts-raw/); fixtures stay tracked. Scope: emit-only. No cross-task learning, memory mutation, prompt rewriting, or optimization.
1 parent 92d8d46 commit ea363b8

6 files changed

Lines changed: 357 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ review/
55
.farmer-state.json
66
bean-stalk.md
77
**/.bean/state.json
8+
**/.bean/runs/
9+
**/.bean/verdicts-raw/
810

911
rs/target/
1012
/bin/bean-check

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- **Trace artifact v0 (emit-only).** Every `bean-run` now writes a stable post-run record to
6+
`.bean/runs/<run_id>.json`: `schema_version`, `run_id`, `goal`, `started_at`/`ended_at`,
7+
`status`, `certificate`, `rounds`, `pivot_count`, `blockers_opened`/`blockers_closed`,
8+
`verifier_verdicts`, `residuals`, `artifacts_changed`, `metadata`. One file per run (not a
9+
rolling file), so future tooling can analyze a corpus of runs without scraping transcripts.
10+
The top-level shape is **fixed** (`additionalProperties: false`); additive fields go under the
11+
`metadata` hatch. Emission **fails closed**: if the trace can't be written, `bean-run` exits
12+
with the infra error code (`3`) rather than reporting a clean run. Schema:
13+
`schemas/trace.schema.json`; docs: `skills/bean/references/trace.md`; conformance asserts the
14+
shape is stable (required keys, no unknown top-level keys). **Scope:** this does NOT make bean
15+
learn across tasks — no memory mutation, prompt rewriting, or cross-run optimization.
16+
317
## 2.0.2 — driver "pivot, don't stop" + review hardening
418

519
Adds the "pivot, don't stop" driver discipline, then hardens it: a cross-model (Codex) review

rs/src/bin/bean-run.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
// Exit: 0 = ready, 4 = converged-with-residuals, 2 = budget-exceeded, 5 = stuck, 3 = usage/load.
2222

2323
use serde_json::Value;
24+
use std::collections::BTreeSet;
2425
use std::path::{Path, PathBuf};
2526
use std::process::{exit, Command, Stdio};
27+
use std::time::{SystemTime, UNIX_EPOCH};
2628

2729
fn die(code: i32, msg: &str) -> ! {
2830
eprintln!("bean-run: {msg}");
@@ -146,6 +148,30 @@ fn frontier(sig: &Value) -> String {
146148
fronts.sort();
147149
format!("{status}\u{1e}{}", fronts.join("\u{1d}"))
148150
}
151+
152+
// the open-front keys (code:claim) in a compiler signal — used for trace blockers_opened/closed.
153+
fn blocker_keys(sig: &Value) -> Vec<String> {
154+
sig.get("blockers")
155+
.and_then(|v| v.as_array())
156+
.map(|a| {
157+
a.iter()
158+
.map(|bl| {
159+
format!(
160+
"{}:{}",
161+
bl.get("code").and_then(|v| v.as_str()).unwrap_or(""),
162+
bl.get("claim").and_then(|v| v.as_str()).unwrap_or("")
163+
)
164+
})
165+
.collect()
166+
})
167+
.unwrap_or_default()
168+
}
169+
170+
fn epoch_ms(t: SystemTime) -> u64 {
171+
t.duration_since(UNIX_EPOCH)
172+
.map(|d| d.as_millis() as u64)
173+
.unwrap_or(0)
174+
}
149175
fn is_active(c: &Value) -> bool {
150176
let st = c.get("status").and_then(|v| v.as_str()).unwrap_or("");
151177
st != "superseded" && st != "rejected" && st != "resolved"
@@ -309,6 +335,17 @@ fn main() {
309335
// a different front/approach) before declaring a true "stuck" stop. ALLOWED_PIVOTS=2 means
310336
// two no-progress rounds are turned into pivots; a third with still no progress is "stuck".
311337
const ALLOWED_PIVOTS: i32 = 2;
338+
// trace artifact v0: stamp the run boundary + accumulate the set of open fronts ever seen.
339+
let run_start = SystemTime::now();
340+
let started_at = epoch_ms(run_start);
341+
let run_id = format!(
342+
"run-{}",
343+
run_start
344+
.duration_since(UNIX_EPOCH)
345+
.map(|d| d.as_nanos() as u64)
346+
.unwrap_or(0)
347+
);
348+
let mut blockers_seen: BTreeSet<String> = BTreeSet::new();
312349
let mut trace: Vec<Value> = vec![];
313350
// Natural max-rounds exhaustion is the hard round ceiling (budget-exceeded, exit 2), NOT
314351
// "stuck": bean-check runs with --no-state here, so it never emits budget-exceeded itself —
@@ -326,6 +363,9 @@ fn main() {
326363
.unwrap_or("")
327364
.to_string();
328365
let front = frontier(&sig);
366+
for k in blocker_keys(&sig) {
367+
blockers_seen.insert(k);
368+
}
329369
let has_blockers = sig
330370
.get("blockers")
331371
.and_then(|v| v.as_array())
@@ -383,6 +423,98 @@ fn main() {
383423
}
384424

385425
let final_sig = compile();
426+
427+
// ---- trace artifact v0 ----------------------------------------------------------------
428+
// Leave behind a stable, useful post-run record so future tooling can analyze runs without
429+
// scraping transcripts. This does NOT make bean learn across tasks; it only emits the trace.
430+
// One file per run (.bean/runs/<run_id>.json) — cross-task analysis needs accumulated runs.
431+
let final_blockers: BTreeSet<String> = blocker_keys(&final_sig).into_iter().collect();
432+
let blockers_closed = blockers_seen
433+
.iter()
434+
.filter(|k| !final_blockers.contains(*k))
435+
.count();
436+
let pivot_count = trace
437+
.iter()
438+
.filter(|t| t.get("pivot").and_then(|v| v.as_bool()).unwrap_or(false))
439+
.count();
440+
let final_claims = read_claims(&bean_dir);
441+
let residuals: Vec<Value> = final_claims
442+
.iter()
443+
.filter(|c| {
444+
c.get("tags")
445+
.and_then(|v| v.as_array())
446+
.map(|a| a.iter().any(|t| t.as_str() == Some("residual")))
447+
.unwrap_or(false)
448+
})
449+
.map(|c| {
450+
serde_json::json!({
451+
"id": c.get("id").and_then(|v| v.as_str()).unwrap_or(""),
452+
"reason": c.get("content").and_then(|v| v.as_str()).unwrap_or(""),
453+
})
454+
})
455+
.collect();
456+
let mut verifier_verdicts: Vec<Value> = vec![];
457+
if let Ok(rd) = std::fs::read_dir(bean_dir.join("verdicts")) {
458+
let mut paths: Vec<PathBuf> = rd
459+
.flatten()
460+
.map(|e| e.path())
461+
.filter(|p| p.extension().map(|x| x == "json").unwrap_or(false))
462+
.collect();
463+
paths.sort();
464+
for p in paths {
465+
if let Some(v) = load_value(&p) {
466+
verifier_verdicts.push(v);
467+
}
468+
}
469+
}
470+
let trace_artifact = serde_json::json!({
471+
"schema_version": "trace/v0",
472+
"run_id": run_id,
473+
"goal": goal,
474+
"started_at": started_at,
475+
"ended_at": epoch_ms(SystemTime::now()),
476+
"status": outcome,
477+
"certificate": final_sig.get("certificate").and_then(|v| v.as_str()).unwrap_or(""),
478+
"rounds": trace.len(),
479+
"pivot_count": pivot_count,
480+
"blockers_opened": blockers_seen.len(),
481+
"blockers_closed": blockers_closed,
482+
"verifier_verdicts": verifier_verdicts,
483+
"residuals": residuals,
484+
"artifacts_changed": Vec::<String>::new(),
485+
// documented extension hatch: additive/experimental fields go HERE, so the top-level
486+
// shape stays fixed (the schema rejects unknown top-level keys).
487+
"metadata": serde_json::Map::new(),
488+
});
489+
// Fail CLOSED on a trace-write failure: the trace is part of bean-run's contract, so we must
490+
// not report a clean exit (0/2/4) if we couldn't persist it. Surface the outcome on stderr,
491+
// then exit with the infra/load error code (3) — distinct from any convergence outcome.
492+
let runs_dir = bean_dir.join("runs");
493+
let trace_path = runs_dir.join(format!("{run_id}.json"));
494+
let wrote = std::fs::create_dir_all(&runs_dir).and_then(|_| {
495+
std::fs::write(
496+
&trace_path,
497+
serde_json::to_string_pretty(&trace_artifact).unwrap() + "\n",
498+
)
499+
});
500+
if let Err(e) = wrote {
501+
eprintln!(
502+
"bean-run: run outcome was {} (cert {}) but the trace could not be persisted",
503+
outcome,
504+
final_sig
505+
.get("certificate")
506+
.and_then(|v| v.as_str())
507+
.unwrap_or("")
508+
);
509+
die(
510+
3,
511+
&format!(
512+
"could not write trace artifact {}: {e} — failing closed",
513+
trace_path.display()
514+
),
515+
);
516+
}
517+
386518
let report = serde_json::json!({
387519
"outcome": outcome,
388520
"rounds": trace.len(),

schemas/trace.schema.json

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://github.com/grainulation/bean/schemas/trace.schema.json",
4+
"title": "bean run trace artifact (v0)",
5+
"description": "Stable post-run record emitted by bean-run to .bean/runs/<run_id>.json. v0 is an EMIT-ONLY artifact: it lets future tooling analyze runs without scraping transcripts. It does NOT make bean learn across tasks — no memory mutation, prompt rewriting, or cross-run optimization. The top-level shape is FIXED: unknown top-level keys are rejected; additive/experimental fields go under `metadata`.",
6+
"type": "object",
7+
"required": [
8+
"schema_version",
9+
"run_id",
10+
"goal",
11+
"started_at",
12+
"ended_at",
13+
"status",
14+
"certificate",
15+
"rounds",
16+
"pivot_count",
17+
"blockers_opened",
18+
"blockers_closed",
19+
"verifier_verdicts",
20+
"residuals",
21+
"artifacts_changed"
22+
],
23+
"additionalProperties": false,
24+
"properties": {
25+
"schema_version": { "const": "trace/v0" },
26+
"run_id": {
27+
"type": "string",
28+
"description": "Unique per run, e.g. run-<nanos-since-epoch>."
29+
},
30+
"goal": {
31+
"type": "string",
32+
"description": "The run goal (from run.json), or a placeholder."
33+
},
34+
"started_at": {
35+
"type": "integer",
36+
"description": "Run start, milliseconds since the Unix epoch."
37+
},
38+
"ended_at": {
39+
"type": "integer",
40+
"description": "Run end, milliseconds since the Unix epoch."
41+
},
42+
"status": {
43+
"type": "string",
44+
"enum": ["ready", "converged-with-residuals", "budget-exceeded", "stuck"],
45+
"description": "Final run outcome."
46+
},
47+
"certificate": {
48+
"type": "string",
49+
"description": "Final convergence certificate from bean-check."
50+
},
51+
"rounds": {
52+
"type": "integer",
53+
"minimum": 0,
54+
"description": "Number of driver rounds executed."
55+
},
56+
"pivot_count": {
57+
"type": "integer",
58+
"minimum": 0,
59+
"description": "No-progress rounds turned into pivots."
60+
},
61+
"blockers_opened": {
62+
"type": "integer",
63+
"minimum": 0,
64+
"description": "Distinct open fronts (code:claim) seen across the run."
65+
},
66+
"blockers_closed": {
67+
"type": "integer",
68+
"minimum": 0,
69+
"description": "Of those, the count not present in the final signal."
70+
},
71+
"verifier_verdicts": {
72+
"type": "array",
73+
"description": "The scrubbed verdicts bean-verify deposited under .bean/verdicts/, embedded verbatim.",
74+
"items": { "type": "object" }
75+
},
76+
"residuals": {
77+
"type": "array",
78+
"description": "Claims tagged residual, with their stated reason (the claim content).",
79+
"items": {
80+
"type": "object",
81+
"required": ["id", "reason"],
82+
"properties": {
83+
"id": { "type": "string" },
84+
"reason": { "type": "string" }
85+
}
86+
}
87+
},
88+
"artifacts_changed": {
89+
"type": "array",
90+
"description": "Files the run changed, when available. v0 does not track this yet; emitted as [] for shape stability.",
91+
"items": { "type": "string" }
92+
},
93+
"metadata": {
94+
"type": "object",
95+
"additionalProperties": true,
96+
"description": "Documented extension hatch. The top-level shape is fixed; additive or experimental fields go here so adding them never breaks v0 validation. Emitted as {} by default."
97+
}
98+
}
99+
}

skills/bean/references/trace.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Reference: trace artifact (v0)
2+
3+
Every `bean-run` writes a stable post-run record to **`.bean/runs/<run_id>.json`**. The point is
4+
to leave behind something a future tool (or a person) can analyze _after_ the run, without
5+
re-reading the transcript.
6+
7+
> **Scope note (read this).** This artifact does NOT make bean learn across tasks. It only emits
8+
> a stable trace/certificate so future tooling can analyze runs. No automatic learning, memory
9+
> mutation, prompt rewriting, or cross-run optimization is part of v0.
10+
11+
One file **per run** (not a single rolling `trace.json`) on purpose: cross-task analysis needs an
12+
accumulated corpus of runs. Summarizing many runs into memory is a later, separate step — don't
13+
start there.
14+
15+
## Shape
16+
17+
Schema: [`schemas/trace.schema.json`](../../../schemas/trace.schema.json) (`schema_version: "trace/v0"`).
18+
19+
| Field | Meaning |
20+
| ------------------------- | ---------------------------------------------------------------------------------- |
21+
| `schema_version` | `"trace/v0"` — bump on any breaking shape change. |
22+
| `run_id` | Unique per run (`run-<nanos-since-epoch>`); also the filename stem. |
23+
| `goal` | The run goal from `run.json`. |
24+
| `started_at` / `ended_at` | Unix epoch milliseconds. |
25+
| `status` | Final outcome: `ready` / `converged-with-residuals` / `budget-exceeded` / `stuck`. |
26+
| `certificate` | Final convergence certificate from `bean-check`. |
27+
| `rounds` | Driver rounds executed. |
28+
| `pivot_count` | No-progress rounds turned into pivots. |
29+
| `blockers_opened` | Distinct open fronts (`code:claim`) seen across the run. |
30+
| `blockers_closed` | Of those, how many are absent from the final signal. |
31+
| `verifier_verdicts` | The scrubbed verdicts from `.bean/verdicts/`, embedded verbatim. |
32+
| `residuals` | Claims tagged `residual`, each `{id, reason}` (reason = the claim content). |
33+
| `artifacts_changed` | Files the run changed, when available. v0 does not track this yet → `[]`. |
34+
| `metadata` | Extension hatch (`{}` by default). Additive/experimental fields go here. |
35+
36+
## Stability rules
37+
38+
- **The top-level shape is fixed.** The schema is `additionalProperties: false` — unknown
39+
top-level keys are rejected. This catches drift and typos. Any new field goes under
40+
`metadata` (the documented hatch), so adding it never breaks v0 validation. A breaking change
41+
to the fixed keys bumps `schema_version`.
42+
- **Emission fails closed.** If `bean-run` cannot persist the trace, it does NOT report a clean
43+
exit. It prints the run outcome to stderr and exits with the infra/load error code (`3`),
44+
distinct from any convergence outcome — the trace is part of the run's contract.
45+
- **Emitted traces are runtime artifacts, not source.** `**/.bean/runs/` (and
46+
`**/.bean/verdicts-raw/`) are gitignored. Do not commit emitted traces. Sample traces needed
47+
by tests live under `test/fixtures/traces/`, never under a `.bean/runs/` path (which is ignored).
48+
49+
## What consumes it (later, not now)
50+
51+
A future hill-climbing loop would read accumulated `runs/*.json` to cluster recurring failure
52+
modes and propose harness improvements. v0 deliberately stops at "stable, useful artifact" so
53+
that future work has a fixed format to build on.

0 commit comments

Comments
 (0)