One JSON document, read and rewritten
A run is a single file: runs/<runId>/run.json. Every mutating
operation loads it, edits it in memory, and writes the whole thing back. That design keeps the
state machine legible and auditable, and it is also the reason several fields in the type never
acquire a value — nothing is watching the document except the five methods that write it.
The document
RunState (types.ts:186–200) has thirteen fields. Four of them —
goal, milestones, evalSelection, budget —
are written once at createRun() and, except for milestone status, never touched
again. Two arrays grow: attemptRecords and attempts. One array,
evalValidationRecords, is initialised empty and stays empty.
Fig. 4.1 Run state — entity relationships
EvaluatorManifest objects rather than
identifiers is the right call for auditability: a run’s record of what it was graded against
cannot be invalidated by later edits to evaluatorRegistry.ts. The amber fields are
the design’s forward declarations — worktree isolation, hidden-eval hashing, and the eval-critic
record are all named in the type system before any of them has an implementation.Fig. 4.2 RunPhase — declared vs. reachable
run.phase today will only
ever see six of the thirteen values, and cannot distinguish “the agent is running” from “the
attempt is open”.Freezing a manifest
freezeEvals() sorts the selected evaluator identifiers, hashes a three-key
object, and stamps the result onto the run. The hash function is 8 lines of arithmetic at the
bottom of the same file.
187function stableHash(value: unknown): string { 188 const json = JSON.stringify(value); 189 let hash = 0; 190 for (let index = 0; index < json.length; index += 1) { 191 hash = (hash * 31 + json.charCodeAt(index)) >>> 0; 192 } 193 return hash.toString(16).padStart(8, "0"); 194}
This is a 32-bit djb-style multiplicative hash, not a cryptographic digest. It is
deterministic and adequate for “did this identifier list change”, and it is not adequate for
tamper detection — node:crypto is already available in the runtime and is not
used. Note also what is hashed: domain, the sorted evaluatorIds, and
requiredProofs. The evaluator contents — publicTests,
hiddenTests, mutationTests — are not part of the input, so an
evaluator can be rewritten entirely without changing the frozen hash.
For the build me Doom run, the input to the hash is fully determined by the
compile stage, so the value is reproducible: with
evaluatorIds = ["browser-game", "playwright-web", "shell-core"] and the nine
browser_game proofs, publicManifestHash is
b5f2049e on every machine and every run.
Fig. 4.3 Scoring & the keep / revert decision
proofScores is data the caller hands in.
The threshold comparison at scoring.ts:17 always uses
goal.doneWhen.minScore, even when the goal object has been narrowed to a single
milestone’s proofs at daedalus.ts:119 — so a one-proof milestone effectively demands
a raw score of 0.85 on that single proof.Budgets are declared and never checked
docs/ARCHITECTURE.md:100–111 makes the case for budgets plainly: “Without
budgets, ‘run until done’ becomes either infinite looping or premature acceptance.” The
RunBudget object is duly constructed for every run.
| Field | Default | Intended effect | Enforcement site |
|---|---|---|---|
| maxAttempts | 50 | stop opening attempts | none |
| maxWallMinutes | 360 | abandon the run after six hours | none |
| minScoreDelta | 0.01 | ignore improvements below the noise floor | none |
| stagnationLimit | 8 | give up after eight flat attempts | none |
| requireHumanPromotion | true | block automatic promotion | none |
Searching the TypeScript sources for budget returns the field declaration at
types.ts:192, the object literal at daedalus.ts:45, and a handful of
unrelated hits on the performance_budget proof name. openAttempt()
pushes a new record unconditionally; there is no comparison against
run.budget.maxAttempts anywhere. The values are correct and inert — they will be
meaningful the moment something reads them, and today a caller can open a five-hundredth
attempt without complaint.
The store
StateStore is 39 lines and does three things: resolve a run directory, write
JSON, read JSON. Its interesting part is the path guard.
25 runDir(runId: string): string { 26 assertRunId(runId); 27 const dir = resolve(this.runsDir, runId); 28 if (!dir.startsWith(`${this.runsDir}`)) { 29 throw new Error(`Invalid run path for ${runId}`); 30 } 31 return dir; 32 } 33} 34 35export function assertRunId(runId: string): void { 36 if (!/^run_[A-Za-z0-9_-]+$/.test(runId)) { 37 throw new Error(`Invalid run id: ${runId}`); 38 } 39}
Two independent checks — a strict identifier pattern and a resolved-prefix comparison — for
a value that arrives from an MCP tool call and becomes a filesystem path. The smoke test
exercises it directly with core.loadRun("../bad"). This is the most defensive code
in the repository, and appropriately so: runId is the only user-controlled string
that reaches node:fs.
The root comes from process.env.DAEDALUS_HOME ?? process.cwd()
(state.ts:9), which is why docs/MCP_USAGE.md insists on setting
DAEDALUS_HOME when the server is launched from a different working directory —
otherwise runs are written relative to wherever the MCP host happened to start.
A run, end to end
$ daedalus init
Initialized Daedalus state directories.
# mkdir -p runs/ artifacts/ research/repos/ (cli/index.ts:16–21)
$ daedalus create-run "build me Doom"
{
"id": "run_kM3vQz8pTa",
"goal": { /* GoalContract, domain browser_game */ },
"milestones": [ /* m0 … m5 */ ],
"evalSelection": { /* 3 evaluators, 0 uncovered */ },
"phase": "goal_created",
"budget": {
"maxAttempts": 50, "maxWallMinutes": 360, "minScoreDelta": 0.01,
"stagnationLimit": 8, "requireHumanPromotion": true
},
"evalValidationRecords": [],
"attemptRecords": [],
"attempts": [],
"status": "planned",
"createdAt": "…", "updatedAt": "…"
}
# written to $DAEDALUS_HOME/runs/run_kM3vQz8pTa/run.json
$ daedalus freeze-evals run_kM3vQz8pTa
{
"id": "evalsnap_7QbN2xLw0d",
"evaluatorIds": ["browser-game", "playwright-web", "shell-core"],
"publicManifestHash": "b5f2049e",
"frozenAt": "…",
"ownerRole": "judge",
"notes": []
}
# phase: goal_created -> evals_frozen
$ daedalus open-attempt run_kM3vQz8pTa m0
{
"id": "attempt_Yh6rL0sVpE",
"milestoneId": "m0",
"status": "opened",
"openedAt": "…"
}
# phase: evals_frozen -> attempt_opened ; status: planned -> running
$ daedalus next run_kM3vQz8pTa
Implement milestone m0: Project boots. Create a runnable project with a clean
build/start path. Required proofs: boots, builds. Do not edit frozen evaluators
unless explicitly assigned the eval-writer role.
Identifiers are nanoid placeholders and
timestamps are elided; every other value is determined by the source. The
publicManifestHash is the real computed value for this goal. The
next text is assembled at daedalus.ts:156–164 — the trailing failure
clause is dropped by .filter(Boolean) when no attempt has been scored yet.
Note that daedalus has no score command. Scoring is reachable only
through the MCP tool daedalus_score_attempt, which is a clear statement of intent:
the CLI is for humans inspecting state, and the loop is expected to be driven by an agent
through MCP.
Sheet notes
scoreRunAttempt()does not require a prioropenAttempt(). If the suppliedattemptIdmatches no record,upsertAttemptRecord()fabricates one withopenedAt === closedAt(daedalus.ts:177–184). If noattemptIdis supplied at all, a freshattempt_<nanoid>is minted.- Every scored attempt is appended to
run.attemptsas well as being upserted intorun.attemptRecords, so a completed run holds two parallel views of the same history — one flat score list and one record list. - Scoring an unknown
milestoneIdthrowsUnknown milestone id: …(daedalus.ts:117). Scoring with nomilestoneIdis legal and grades against the whole goal’s required proofs. - A milestone can only be marked passed by a scoring call that names it. Scoring the goal as
a whole leaves every milestone
"pending", which meansstatuscan never reach"complete"without per-milestone scoring. - There is no delete, archive or list operation.
StateStoreexposessaveRun,loadRunandrunDironly; enumerating runs means reading the directory yourself.