Sheet 02Goal contract & milestone planScale: NTS

Compiling a sentence into a contract

Everything downstream of createGoalContract() depends on one decision: which of nine domains the goal text belongs to. That decision is made by counting substring hits from a hand-written keyword table, and it is the single most consequential — and most fragile — eleven lines in the repository.

The compiler is 24 lines long

There is no model call, no embedding, no parsing. createGoalContract() classifies the domain, looks up two fixed lists, attaches four fixed constraints and stamps a threshold.

packages/core/src/goalCompiler.ts · lines 5–28verbatim
5export function createGoalContract(goal: string): GoalContract {
6  const domain = classifyDomain(goal);
7  const requiredProofs = defaultProofsForDomain(domain);
8
9  return {
10    id: `goal_${nanoid(10)}`,
11    goal,
12    domain,
13    deliverables: defaultDeliverablesForDomain(domain),
14    requiredProofs,
15    constraints: [
16      "artifact must run from a clean checkout",
17      "verification must be reproducible from command line",
18      "do not count placeholder-only behavior as complete",
19      "store eval artifacts for later audit"
20    ],
21    doneWhen: {
22      minScore: domain === "unknown" ? 0.75 : 0.85,
23      requiredGatesPass: true,
24      noRegressions: true
25    },
26    createdAt: new Date().toISOString()
27  };
28}

The constraints array is identical for every goal in every domain. The doneWhen.minScore takes one of two values. requiredGatesPass and noRegressions are hard-coded true and, as sheet 04 shows, requiredGatesPass is never read by the scorer.

Fig. 2.1 Goal compilation — elevation

Elevation of the goal compilation pipeline from raw text to GoalContract Goal text is lower-cased, then scored against eight keyword groups by substring inclusion. The results are sorted by descending hit count and the top entry is taken; if its score is zero the domain falls back to unknown. The resolved domain then fans out to three lookups — default required proofs, default deliverables, and the minimum score threshold — which combine with a fixed constraints array and a nanoid identifier to produce the GoalContract object. Callouts mark the stable-sort tie-break, the substring matching hazard, and the two possible threshold values. classifyDomain() — domain.ts:38–48 goal: string “build me Doom” toLowerCase() no tokenisation keywordDomains[8] browser_game 10 kw web_app 9 kw api 7 kw cli 4 kw data_ml 8 kw infra 6 kw research 6 kw library 6 kw score = keywords .filter(includes).length .sort((a,b) => b.score - a.score) scored[0]?.score ? d : "unknown" domain: Domain one of 9 literal values defaultProofsForDomain switch, 9 arms → Proof[] 1 to 9 proofs per domain defaultDeliverables switch, 5 arms + default 3–4 strings, prose only doneWhen.minScore 0.75 if domain unknown 0.85 otherwise GoalContract id `goal_${nanoid(10)}` · goal · domain · deliverables requiredProofs · constraints[4] · doneWhen · createdAt 1 2 1 · Ties break by array order, because sort is stable. browser_game is declared first and wins every tie. 2 · text.includes(kw) is raw substring matching. The web_app keyword "ui" is contained in the word "build", so almost every goal scores web_app ≥ 1.
Fig. 2.1 — Classification is eleven lines (domain.ts:38–48) and has no fallback beyond "unknown". Callout 2 is not hypothetical: the string "build" contains "ui", which is a web_app keyword at domain.ts:10, so any goal beginning with the word “build” carries a permanent web_app point.

What the tie-break actually does

The interaction between callouts 1 and 2 is easy to demonstrate. Running the classifier over three goals gives:

classifyDomain() traced over sample goal text
Goal textKeyword hitsResult
build me Doom browser_game: doom
web_app: ui (inside “build”)
browser_game
build a CLI that renames files web_app: ui (inside “build”)
cli: cli
web_app
make a command-line tool that renames files cli: command-line cli

Row two mis-classifies. Both domains score 1; web_app is declared at domain.ts:9 and cli at domain.ts:17, so the stable sort leaves web_app first. The consequence is not cosmetic: the goal then requires user_interaction, visual_nontrivial and responsive_layout instead of cli_golden_output, and gets a Playwright evaluator instead of a golden-output evaluator. The smoke test does not cover this case — it only asserts the browser_game path, where doom plus ui happens to tie in the right order.

The word “Doom” is not incidental. RESEARCH.md opens by naming build me Doom as the motivating request, and domain.ts:6 lists "doom" as a literal browser_game keyword alongside "three.js" and "phaser". The classifier is tuned to its own demo.

Fig. 2.2 Domain / required-proof matrix

Matrix of which of the 17 proof kinds each of the 9 domains requires by default A nine-row by seventeen-column grid. Rows are the Domain union members; columns are the Proof union members in declaration order. A filled cell means defaultProofsForDomain returns that proof for that domain. browser_game requires nine proofs, web_app and api six each, data_ml four, cli infra and unknown three each, library two, and research one. The proofs responsive_layout, cli_golden_output, data_invariants and citation_quality are each required by exactly one domain. No domain requires tests_pass together with the four game proofs. boots builds tests_pass user_interaction visual_nontrivial responsive_layout game_controls game_collision game_combat game_win_loss performance_budget api_contract auth_behavior cli_golden_output data_invariants citation_quality security_baseline browser_game9 web_app6 api6 data_ml4 cli3 infra3 unknown3 library2 research1 FILL = required by default infra is the only domain whose default proofs include a proof that no evaluator can serve — it is reported in EvalSelection.uncoveredProofs
Fig. 2.2 — Every cell is transcribed from the switch statement at domain.ts:50–81. The single red cell is the only combination in the whole default matrix that produces an uncovered proof; it is traced on sheet 03. Note the absence of tests_pass from the browser_game row — a game goal never asks for a passing test suite, only for gameplay evidence.

Milestones: a list, not a graph

README.md and docs/ARCHITECTURE.md both describe a “milestone DAG”. The implementation, planMilestones(), returns Milestone[], and the Milestone interface (types.ts:46–53) has no dependsOn, parents or edges field. There are four hard-coded plans: one each for browser_game, web_app and api, plus a three-milestone fallback used by the other six domains.

planMilestones() — milestones.ts:7–41
DomainIDTitleRequired proofsWeight
browser_gamem0Project bootsboots, builds0.15
m1Scene rendersvisual_nontrivial0.15
m2Player controluser_interaction, game_controls0.15
m3World rulesgame_collision0.15
m4Combat loopgame_combat0.20
m5Objective and polishgame_win_loss, performance_budget0.20
web_appm0Project bootsboots, builds0.25
m1Core workflowuser_interaction0.35
m2Visual qualityvisual_nontrivial, responsive_layout0.25
m3Regression checkstests_pass0.15
apim0Server bootsboots, builds0.25
m1Contractapi_contract0.35
m2Behaviorauth_behavior, tests_pass0.30
m3Hardeningsecurity_baseline0.10
all other
domains
m0Runnable baselinebuilds0.35
m1Core behaviorgoal.requiredProofs (spliced in)0.45
m2Regression guardtests_pass0.20

The weight column is written and never read. Grepping the TypeScript sources for weight returns exactly three hits: the field declaration at types.ts:51, the helper parameter at milestones.ts:3, and the object literal at milestones.ts:4. scoring.ts computes an unweighted mean over required proofs and never inspects the milestone list.

The fallback plan has a subtle property worth noting. Its m1 takes goal.requiredProofs wholesale, so for a cli goal m1 requires builds, tests_pass, cli_golden_output — the same tests_pass that m2 also requires, and the same builds that m0 already required. Milestones in the fallback path overlap rather than partition.

Fig. 2.3 browser_game milestone chain

The six-milestone browser_game plan with weights and required proofs Six milestone blocks m0 through m5 arranged left to right with sequential arrows: project boots, scene renders, player control, world rules, combat loop, and objective and polish. Below each block the required proofs are listed and a weight bar shows the relative weight, 0.15 for the first four and 0.20 for the last two. A dimension line beneath spans the full set and is annotated total weight equals 1.00, unused. A note records that proposeNextTask simply returns the first milestone whose status is not passed, so traversal is strictly left to right regardless of weight. planMilestones({ domain: "browser_game" }) → Milestone[6] m0 Project boots boots builds m1 Scene renders visual_nontrivial m2 Player control user_interaction game_controls m3 World rules game_collision m4 Combat loop game_combat m5 Objective game_win_loss performance_budget weight (declared, never read) 0.15 0.15 0.15 0.15 0.20 0.20 Σ weight = 1.00 (unused) Milestone.status transitions that actually occur "pending" "passed" "in_progress" "failed" decision === "keep" declared in types.ts:52 — no assignment site anywhere in the repo proposeNextTask() returns milestones.find(m => m.status !== "passed") — traversal is index order, weight-blind, and cannot skip ahead. daedalus.ts:148
Fig. 2.3 — Milestones advance only forward and only to "passed". scoreRunAttempt() sets targetMilestone.status = "passed" when the decision is keep (daedalus.ts:133–137) and never writes any other status value, so a milestone that has failed six attempts is indistinguishable from one nobody has started.

Compiling the demo goal

daedalus plan runs all three compile steps and prints the combined result as JSON without touching disk. Below is the head of that output for the goal named in RESEARCH.md; the identifier and timestamp vary per invocation.

apps/cli/src/index.ts:23–32 — plan <goal...>
$ daedalus plan "build me Doom"
{
  "goal": {
    "id": "goal_V1StGXR8_Z",
    "goal": "build me Doom",
    "domain": "browser_game",
    "deliverables": [
      "runnable game app",
      "local start command",
      "gameplay tests",
      "screenshots or video artifacts"
    ],
    "requiredProofs": [
      "boots", "builds", "visual_nontrivial", "user_interaction",
      "game_controls", "game_collision", "game_combat",
      "game_win_loss", "performance_budget"
    ],
    "constraints": [ /* 4 fixed strings */ ],
    "doneWhen": {
      "minScore": 0.85,
      "requiredGatesPass": true,
      "noRegressions": true
    },
    "createdAt": "2026-01-01T00:00:00.000Z"
  },
  "milestones": [ /* m0 … m5, see Fig. 2.3 */ ],
  "evalSelection": {
    "domain": "browser_game",
    "requiredProofs": [ /* same 9 */ ],
    "evaluators": [ /* shell-core, playwright-web, browser-game */ ],
    "uncoveredProofs": []
  }
}

Structure and values transcribed from the source; identifier and timestamp shown as placeholders because nanoid(10) and new Date() differ per run. plan is read-only — create-run is the command that persists.

Sheet notes

  1. Two YAML templates in templates/ describe the same demo goal by hand: goal.yaml (12 lines, matching the compiler output) and doom.goal.yaml (24 lines, with a richer ten-item requirements list). Neither is read by any code — there is no YAML parser in the dependency list.
  2. Proof is a closed union of 17 string literals (types.ts:12–29). A goal cannot introduce a new proof kind without editing the type, which also means the evaluator registry can be checked exhaustively at compile time.
  3. deliverables is free prose consumed by nothing. It exists to be shown to a builder agent.
  4. The classifier is case-insensitive but whitespace- and punctuation-blind: it never tokenises, so hyphenation and word boundaries are irrelevant to matching.