bareloop 0.16.0 → 0.18.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,57 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
- ## [Unreleased]
8
+ ## [0.18.0] — 2026-08-31
9
+
10
+ ### Added
11
+
12
+ - **Close stages declare their comparison direction (F120).** `job-v1` close stages gain an
13
+ optional `direction: 'up'|'down'` field; absent means `'down'` (unchanged behaviour for
14
+ every existing close). `src/trend.js`'s close-fix governor hardcoded lower-is-better —
15
+ wrong for a rate-shaped close, where up is better — and cap-halted a converging G2 run
16
+ for improving (live on `u-mtgr1qnu`); a mirror defect funded a collapsing rate as
17
+ progress. Direction is never inferred from goal prose and never LLM-judged; a new
18
+ `isBetter(stage,a,b)` helper in `src/trend.js` drives `countImproved` and both
19
+ `best`-tracking sites, `createTrend` gains a `directions` option, `STAGE_FIELDS`
20
+ (`src/job.js`) validates the field against an allow-list. `scripts/run-u.mjs` prints each
21
+ stage's direction at the close-stage banner. 7 new tests, each proven failing against the
22
+ old code first; both production job hashes independently re-verified unchanged.
23
+
24
+ ### Changed
25
+
26
+ - **`--read-shim` defaults to A1 (cap) in `scripts/run-u.mjs`** (hamr's call, 2026-08-31).
27
+ `--read-shim off` restores A0. The three frozen bench rows in `docs/product/BENCH-PREREG.md`
28
+ pin `off` explicitly, so their baselines keep the condition they were established under —
29
+ no bench re-baseline required or paid.
30
+
31
+ ### Fixed
32
+
33
+ - **`resolveWorkerModel` is now exported from the package root.** `bareloop.context.md`
34
+ names it as the spec-model resolution mechanism, but v0.17.0 shipped it reachable only
35
+ in-repo (`src/job.js`) — the exports map admits only `.`, so an adopter could not import
36
+ it. Documented-but-unexported is a false contract (same rule that exported `checkMenu`);
37
+ caught by validating the installed 0.17.0 artifact from the registry. Additive only.
38
+ - **G2 cold-variant `clean-run` close dropped a vacuous stage.** The borrowed
39
+ `changed-from-seed` stage compared against a seed manifest that can never show a change
40
+ on a genuinely cold patient (zero files under `tests/testgen/` at seed), firing a spurious
41
+ `unchanged-red` before the worker had written anything. New
42
+ `scripts/testgen-cold-check-close.mjs` (grader-identical minus that stage);
43
+ `jobs/aurora-testgen-cold.json`'s `clean-run` stage repoints to it. Live-proven: the
44
+ re-establish run recorded zero `unchanged-red` events.
45
+
46
+ ## [0.17.0] — 2026-08-31
47
+
48
+ ### Added
49
+
50
+ - **Publish workflow gates on the types being usable BY AN ADOPTER, not just internally.** `npm run typecheck` (`tsc --noEmit`) checks the *source*; it cannot see the generated `.d.ts` as an adopter resolves it from inside `node_modules`, which is the one thing consumers actually get. The publish workflow now packs the tarball, installs it into a clean consumer project, and compiles a quickstart against it, so a release whose published types are broken cannot reach the registry. The consumer pins `@types/node` to the major this package builds against instead of floating to the newest, so a stricter DefinitelyTyped release cannot turn the publish gate red for reasons unrelated to the commit being published. Verified locally: the quickstart compiles green against a packed tarball, and a deliberately broken dereference fails it. CI only — no runtime or published-artifact change.
51
+
52
+ - **Worker model name in the signed spec (build-list #3)**: `job-v1` gains an optional
53
+ `model` field — a non-empty string naming the exact worker provider model id. Absent,
54
+ behaviour is unchanged (the runner's own default); present, it is part of the signed
55
+ spec hash and `scripts/run-u.mjs` runs the job on that model. A `--model <tier>` flag
56
+ naming a different model is refused (`resolveWorkerModel`, `src/job.js`) rather than
57
+ silently overridden — the spec always wins. Worker only; the judge model stays
58
+ library-pinned pending recalibration.
9
59
 
10
60
  ## [0.16.0] — 2026-08-30
11
61
 
@@ -52,6 +52,35 @@ inherited rule carries the green that minted it and the contrast that attributed
52
52
  - **Is not:** a general agent, a swarm, or an orchestrator framework. One-off or small
53
53
  jobs are out of scope — that's a CLI session, not a bareloop job.
54
54
 
55
+ ## Which mode / components do I need?
56
+
57
+ - **Verdict class — how "done" gets decided:** `green` (a mechanical close: exit code =
58
+ truth, `src/kinds.js` `LIVE_KINDS`) is the default and the only class with a live-proven
59
+ paid run. `soft-green` (a judged floor behind a signed calibration gate, plus a review
60
+ door) is fully admitted (`VERDICT_TYPES`, `src/job.js:106`) but has never rendered a live
61
+ verdict — see `## Public API` → "THE JUDGED FLOOR" and "THE REVIEW DOOR" below. `hitl` is
62
+ LEGACY: still admitted (removal is a future breaking change) but retired as a design
63
+ (PRD Addendum v1.71); its pause machinery lives on as the review door, not as a class to
64
+ pick for new jobs.
65
+ - **Worker surface — how the agent runs:** `provider: 'anthropic-api'` (the default, the
66
+ only one with an operational guarantee, F48) drives `bare-agent`'s `Loop` and takes the
67
+ shell-owned `provider` binding. `provider: 'clipipe-subscription'` (`src/job.js:135`)
68
+ drives the worker natively over the Claude CLI's own tool channel and requires
69
+ `opts.nativeProvider` in `runJob`/`runPlan` — its `costUsd` axis is notional, never pools
70
+ with `anthropic-api` on cost, and budgets don't transfer between them.
71
+ `job.provider === 'clipipe-subscription'` with no `nativeProvider` supplied is
72
+ `interpreter-red`, never a silent fall-back to the metered API (`src/planrun.js:1073`).
73
+ - **Reuse — where a workflow comes from:** a plain `runJob` always drafts cold. Passing
74
+ `bridge` starts from one standalone bridge file (`src/reuse.js`'s envelope, `## The reuse
75
+ ENVELOPE and runReuse` below). The CLI's `--registry <dir>` / library-level `registryDir`
76
+ (`src/reuse.js`, `scripts/run-u.mjs`) instead reads/writes dated rows in an
77
+ operator-supplied directory of plain JSON files — no database, no default location; a
78
+ named-but-missing registry is a red, never conjured (`## The reuse registry (Layer 3)`
79
+ below).
80
+ - **Read shim:** `readShim` defaults to `false` on both `runJob` (`src/run.js:205`) and
81
+ `runPlan` (`src/planrun.js:808`) — the OFF arm wraps nothing at all. Opt in with `true`,
82
+ `'cap'`, or `'diff'` (`src/readshim.js`) only once the read-hygiene tradeoff is wanted.
83
+
55
84
  ## Minimal usage
56
85
 
57
86
  ```js
@@ -106,6 +135,7 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
106
135
  | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
107
136
  | `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
108
137
  | `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage number, so a spec with many stages can validate while its overshoot dwarfs its cap: the clock reports that honestly rather than the floor pretending to prevent it (stage-aware floor parked, PRD v1.39). Operator-only, tighten-only; adding or changing it changes the spec hash |
138
+ | `model` | optional non-empty string, exact provider model id (e.g. `"claude-sonnet-5"`) | the WORKER's model, build-list #3. Absent means today's runner default; present it wins over a `--model` flag outright, and a flag naming a different id is refused (`resolveWorkerModel`, `src/job.js`) rather than silently overridden. Part of the signed hash like every other field. Worker only — the judge model stays library-pinned (`JUDGE_MODEL`) pending recalibration |
109
139
  | `writeScope` | array of contained globs | the operator's outer fence; the plan's own scopes must fit inside it, same containment code |
110
140
  | `steps` | RETIRED | operator-authored `steps[]` was deleted (PRD v1.32); a spec carrying it reds `shape-retired:steps` by name rather than half-running |
111
141
  | `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
@@ -117,7 +147,7 @@ the step plan at run time (gated by `validatePlan`); the human signs only:
117
147
  |---|---|---|
118
148
  | `goal` | non-empty text | what the agent plans against |
119
149
  | `verdictType` | `green` \| `soft-green` \| `hitl` | declared radio, never inferred (`VERDICT_TYPES`, frozen). **All three are ADMITTED today** — `LOCKED_VERDICTS` is empty, so nothing reds `request-red` on the class itself. `soft-green` has its judged floor (`judged-floor`, signed rubric card + calibration gate) and is the forward path for a job needing judgement, paired with the end-of-run review door; `hitl` is LEGACY — retired as a design direction (see the direction note at the top) but still admitted and still works exactly as documented here. Every `request-red` (e.g. for a locked TOOL verb, not a verdict class) also carries `lib` — the territory the demand lands against, stamped at the emit site (`verdictType` → `bareloop`, a locked tool verb → `bare-agent`): the ledger keys and its `suggestedAsk` seed on it, so a bareloop-catalogue refusal never files as an upstream ask |
120
- | `close` | **an ORDERED LIST of named stages** `[{ name, cmd, expect, judged?, gapKeep?, offer?, needs? }, ...]` (PRD v1.28), or a close object (table below) | the destination, the only thing hand-authored; the check menu DERIVES from it (below). The plan flow executes a staged close directly and adapts a bare `predicate` object into a one-stage list; a `gold`/`rubric`/`hitl` object close still VALIDATES (`CLOSE_TYPES`) but the plan flow refuses all three at runtime as `close-unsupported` — an object close names no command to run. The modern route to a judged or human-decided close is a staged `closeDecl` (`judged-floor` / `human-confirms` stage kinds), not this object form |
150
+ | `close` | **an ORDERED LIST of named stages** `[{ name, cmd, expect, judged?, gapKeep?, offer?, needs?, direction? }, ...]` (PRD v1.28), or a close object (table below) | the destination, the only thing hand-authored; the check menu DERIVES from it (below). The plan flow executes a staged close directly and adapts a bare `predicate` object into a one-stage list; a `gold`/`rubric`/`hitl` object close still VALIDATES (`CLOSE_TYPES`) but the plan flow refuses all three at runtime as `close-unsupported` — an object close names no command to run. The modern route to a judged or human-decided close is a staged `closeDecl` (`judged-floor` / `human-confirms` stage kinds), not this object form |
121
151
  | `closeDecl` | **the AUTHORED close** `{ genre: "TYPES", lang: "js"\|"python", stages: [{ name, kind, params }], notes? }` | the ALTERNATIVE to `close`, and the point of the close-authoring rung: the user answers an interview and an LLM composes a DECLARATION over kinds whose implementations we own — never a script, never a shell fragment, never a new kind. **`close` and `closeDecl` are alternatives**: declaring both reds `close-duplicated` (two closes are two arbiters). It is HARD-class by construction (`CLASS_BY_CLOSE.declared`), so a locked verdict on one reds `close-hierarchy` as well as `request-red`. The declaration is validated by `validateCloseDecl`; the TREE-GROUNDED half of that gate (the path rule, and the scoped-job derivation that arms the F84 one-population law) is DEFERRED at spec-validation time — a job spec is validated with no repository in hand — and the runner re-runs it GROUNDED against the real seed before any stage and before any token. It stores the counting RULE and never a number (D12): there is no seed field, and `baseline: "seed"` is measured at each run's own HEAD |
122
152
  | `checks` | **RETIRED** (PRD v1.28/v1.32) | hand-authored checks are gone, not merely discouraged: declaring `checks` reds `checks-derived` by name. The check menu is DERIVED from the close's own stages instead — see **Staged close** below. The hazard this removes is measured, not theoretical: job #5's three hand-written checks were re-implementations of three stages the close already ran, and a hand-carved copy can drift LENIENT (the worker passes the operator's ruler and fails the real inspection) |
123
153
  | `tools` | optional unique subset of `TOOL_MENU` (14 verbs, below) | the CEILING every plan step's grant must fit inside (omitting it means the full menu — and the hash is taken over that RESOLVED form, so a `TOOL_MENU` widening flips an omitted-`tools` spec's hash and forces a re-sign; see `jobSpecHash`, MED-1); `run` is `LOCKED_TOOLS` and reds `request-red` — locked-but-listed, and the red IS the admission evidence the ledger tallies (a typo stays `invalid-value`). A ceiling of write-class and store-class verbs ONLY reds `invalid-value`: the scout surveys read-only, so it would be handed an empty menu and survey blind |
@@ -175,6 +205,7 @@ contract the single close object has always used) plus:
175
205
  | `name` | unique kebab-case slug | the plan references it via `check-passes(name)`; duplicates red `duplicate-id` |
176
206
  | `offer` | optional boolean, default offered | `offer: false` hides the stage from the derived menu — it never reaches the agent. For a stage that cannot stand alone as a ruler: a PRECONDITION (e.g. "the seed commit exists" — passes instantly, teaches nothing) or the **final grading stage** — of the ten specs in `jobs/`, four hide a final `verdict` stage and six hide a `changed-from-seed` precondition instead (a per-spec convention, not a schema rule — nothing stops a stage named last from being offered except the spec author remembering to set the flag; preflight's `check-menu` event always names the full menu either way, so a forgotten flag is visible in the run's own record) |
177
207
  | `needs` | optional non-empty array of EARLIER stage names | a stage that reads what an earlier stage built (e.g. "the public API matches what an earlier stage emitted") names its prerequisite chain; picking it via `check-passes` runs the chain first, then the stage itself. Every name must be declared before this stage (`invalid-value` otherwise); `needs` + `offer: false` together is incoherent (`invalid-value`) — a stage with a chain to run must be reachable |
208
+ | `direction` | optional `'up'` \| `'down'`, allow-list validated (anything else is `invalid-value`) | which way this stage's numeric series is READING as progress — `'down'` (fewer is better: typecheck errors, suppressions) or `'up'` (more is better: a fault-detection rate). **Absent means `'down'`** — every close built before this field existed is a count-shaped close, so the silent default is behaviour-identical for all of them (F120). Never inferred from the goal text and never LLM-judged — prose the agent writes must never influence its own halt rule, the same reasoning that keeps every close hand-authored or declared, never judged. Consulted by `isBetter(stage,a,b)` in `src/trend.js`, the one place the close-fix governor's `countImproved` and `best`-tracking read direction; `scripts/run-u.mjs` prints each stage's direction at the close-stage banner so a silent default is never invisible |
178
209
 
179
210
  The stages run in declared order as the close itself; the first red renders the verdict and
180
211
  later stages never run. `checkMenu(close)` returns only the offerable stages (each with its
@@ -2480,6 +2511,73 @@ JSONL spine (single source for every UI), litectx store per job, per-run ledger.
2480
2511
  the bare suite: bareagent, bareguard, litectx, barebrowse, baremobile — the full surface
2481
2512
  is disclosed to the authoring agent; only admitted verbs are callable per job.
2482
2513
 
2514
+ ## Extension contract
2515
+
2516
+ hamr's law, verbatim: **the agent may author anything whose only verbs are gated
2517
+ primitives; it may never author the arbiter — the close, the budget, the fence, the
2518
+ merge.** What an adopter can plug into `runJob` (`src/run.js:205`):
2519
+
2520
+ - **Providers.** `provider` (shell-owned LLM binding for the `Loop`/API path),
2521
+ `nativeProvider` (a factory required for a `clipipe-subscription` job),
2522
+ `providerFor(tier)` (per-step model-tier provider, forwarded to the plan flow), and
2523
+ `judgeProvider` (SOFTGREEN's own seam — the provider a judged close stage runs its
2524
+ locate-only call through; distinct from `provider`/`providerFor` because the judge tier
2525
+ is fixed, never the worker's). All four are documented at `src/run.js:91-101`.
2526
+ - **Close stage kinds.** The catalogue is fixed and enumerated (`LIVE_KINDS`,
2527
+ `src/kinds.js:142`: `command-exit`, `count-not-worse`, `pattern-absent-in-diff`,
2528
+ `files-changed`, `human-confirms`, `judged-floor`). An adopter's declaration SELECTS
2529
+ from this menu; it never authors a new kind — a stage naming an unimplemented or unknown
2530
+ kind is a hard runtime stop (`src/kinds.js:1885-1888`), not a way to run adopter code
2531
+ inside the close.
2532
+ - **`emit` — the spine sink.** `makeSpine(file)` (`src/spine.js:21`) returns an
2533
+ `emit(type, data)` that appends one JSONL line per event; any function with that
2534
+ signature can stand in as a custom sink, but it is write-only — nothing in bareloop reads
2535
+ the spine back, so a custom sink cannot feed anything into a run's own decisions.
2536
+ - **`rates` is NOT an extension point.** A rates passthrough was designed but never built;
2537
+ pricing is guesstimate-plus-loud-`estimated`-flag by ruling, and rate tables are the
2538
+ customer's own responsibility, not a bareloop seam to wire up.
2539
+
2540
+ Everything else in `runJob`'s options — budgets, caps, the close, the fence, merge/publish
2541
+ — is arbiter territory and is never adopter-suppliable as behavior, only as signed,
2542
+ schema-validated data.
2543
+
2544
+ ## Threat model summary
2545
+
2546
+ **Local-trust model, NOT a sandbox.** The worker runs with the OS user's own permissions.
2547
+ Both the close (`src/ralph.js:222`) and a declared close stage's environment
2548
+ (`src/kinds.js:351`) start from a full copy of `process.env` and spawn child processes
2549
+ under it — this is a passthrough, not a jail.
2550
+
2551
+ **What the arbiter guarantees:**
2552
+ - **Cap-not-estimate money and time.** `budgetUsd` is validated `0 < budget <= shellCapUsd`
2553
+ with the check's own name, `cap-not-estimate` (`src/job.js:227`); spend is never a
2554
+ projection.
2555
+ - **A signed spec hash refuses drift.** `jobSpecHash`/`checkApproval` (`src/job.js:703,
2556
+ 723`) mean an edited spec is unapproved by construction; `runJob` red-lines
2557
+ `'unapproved-spec'` before any provider call if no approval record matches the exact spec
2558
+ version (`src/run.js:289-290`).
2559
+ - **Secrets never enter the tree, the spine, the ledger, or a close's child process.**
2560
+ `redactSecrets` (`src/validate.js:130`) scrubs at every emission boundary in the
2561
+ authoring path; `CLOSE_ENV_DENY` (`src/ralph.js:136-153`) strips provider keys and
2562
+ known credential-shaped env-var names from the copy of `process.env` handed to a close
2563
+ child, by name/prefix/suffix-shape — a strip of a COPY, so the host process (and
2564
+ bare-agent, which reads the real key every round) keeps its own credentials.
2565
+ - **The worker is fenced off the arbiter's own books.** The authoring scout's read scope
2566
+ denies the gate audit path and every `ARBITER_BOOK_STORES` path under the workdir
2567
+ (`src/authorscout.js:295`); a plan step's read scope is the workdir alone
2568
+ (`src/planrun.js:1894`).
2569
+
2570
+ **What it does not guarantee:** no network egress control and no container/VM isolation —
2571
+ this repo has neither built nor documented either. `CLOSE_ENV_DENY`'s deny rule matches
2572
+ only credential-shaped names (`src/ralph.js:136-153`); any env var that doesn't match that
2573
+ shape passes through to the close's child process untouched. `SSH_AUTH_SOCK` is one of them
2574
+ and stays on purpose — stripping it would break git-over-SSH inside a close — but the deny
2575
+ list itself carries no comment saying so; this paragraph is the record.
2576
+
2577
+ **What an adopter must do:** keys load from the environment only (`.env.example` below);
2578
+ the patient is always a COPY of the target repo, never the original — the copy is the
2579
+ blast radius.
2580
+
2483
2581
  ## What's NOT in bareloop, and why
2484
2582
 
2485
2583
  - **No agent-authored arbiters.** Closes, budgets, caps, merge/publish decisions live
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.16.0",
4
- "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
3
+ "version": "0.18.0",
4
+ "description": "Workflows that earn their own design, with receipts \u2014 agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
package/src/index.js CHANGED
@@ -24,7 +24,7 @@ export { makeSpine } from './spine.js';
24
24
  // name. Documented-but-unexported is a false contract; the additions are additive.
25
25
  export { ralph, runClose, runStages, CLOSE_FAULTS } from './ralph.js';
26
26
  export { globToPrefix, scanSecrets } from './validate.js';
27
- export { validateJob, jobSpecHash, checkApproval, checkMenu, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
27
+ export { validateJob, jobSpecHash, checkApproval, checkMenu, resolveWorkerModel, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
28
28
  // Layer 2: the plan-v1 validator — the agent-authored plan doc's gate; the
29
29
  // two-doc split's third validator never happens (plan-v1 gates the PLAN, the
30
30
  // job spec stays the arbiter's only home).
package/src/job.js CHANGED
@@ -140,7 +140,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
140
140
  // `steps` stays in the field list ONLY so a retired spec reds by name
141
141
  // (`shape-retired`) instead of falling through to a generic unknown-field —
142
142
  // the operator gets told what happened, not just that something is wrong.
143
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
143
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
144
144
  /** the four-field plan shape's core (decision 5) — presence of any of these
145
145
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
146
146
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
@@ -240,6 +240,17 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
240
240
  `integer milliseconds >= ${MIN_WALL_MS} (one close timeout). Enforcement is a between-round deadline, measured as maxWallMs + closeStages x closeTimeoutMs (design addendum 1: loop.stop() cannot cut an in-flight call; W5: every close stage runs under the full timeout), so a budget under one close cannot fund its own close and the advertised number would be more wrong than right`);
241
241
  }
242
242
 
243
+ // MODEL — the worker's provider model id (build-list #3, hamr's GO
244
+ // 2026-08-30). OPTIONAL, exactly like budgetUsd/maxWallMs before it: absent
245
+ // means today's behaviour (the runner's own default), present it is part
246
+ // of the signed hash — jobSpecHash canonizes the whole resolved spec
247
+ // (resolveSpec), so a spec that names a model is an operator signing off
248
+ // on THAT model, not merely on the goal/close it runs against. The judge
249
+ // model stays library-pinned pending recalibration — out of scope here.
250
+ if (spec.model !== undefined && !isNonEmptyString(spec.model)) {
251
+ red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
252
+ }
253
+
243
254
  // 3. the outer write fence — operator law (interview decision #4), same
244
255
  // containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
245
256
  // transforms would let a fence validate green and mean something else).
@@ -481,7 +492,7 @@ export function checkMenu(close) {
481
492
  }
482
493
 
483
494
  /** the fields a close STAGE may carry — anything else is a smuggle channel */
484
- const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs'];
495
+ const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs', 'direction'];
485
496
 
486
497
  /**
487
498
  * Validate the staged close. Every stage is a predicate BODY under the same
@@ -523,6 +534,17 @@ function validateStagedClose(stages, red) {
523
534
  if (bad.length) red('invalid-value', `${at}.needs`, `must name stages declared BEFORE this one (a prerequisite that has not run yet cannot be one): ${bad.join(', ')} — earlier stages: ${[...earlier].join(', ') || 'none'}`);
524
535
  }
525
536
  }
537
+ // the close-fix governor's comparison direction (2026-08-31 ruling, hamr's
538
+ // option A): a signed, per-stage field, never inferred from goal prose and
539
+ // never LLM-judged. 'up' = higher is better (a rate of good things caught,
540
+ // e.g. mutation-kill %); 'down' = lower is better (a count of bad things
541
+ // remaining, e.g. typecheck errors or suppressions). Absent means 'down' —
542
+ // the field defaults to the trend reader's pre-existing behaviour exactly,
543
+ // so every close authored before this field existed keeps an IDENTICAL
544
+ // spec hash (an absent field adds nothing to the hash).
545
+ if (s.direction !== undefined && s.direction !== 'up' && s.direction !== 'down') {
546
+ red('invalid-value', `${at}.direction`, `"up" or "down" — up means higher is better (a rate of good things caught), down means lower is better (a count of bad things remaining); omit for down (the default)`);
547
+ }
526
548
  });
527
549
  }
528
550
 
@@ -732,3 +754,30 @@ export function checkApproval(job, approvals) {
732
754
  const h = createHash('sha256').update(c).digest('hex');
733
755
  return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
734
756
  }
757
+
758
+ /**
759
+ * Pure decision: which worker model id a run actually uses (build-list #3,
760
+ * hamr's GO 2026-08-30). The signed spec's `model`, if present, is what an
761
+ * operator SIGNED — it wins outright. A `--model` runner flag naming a
762
+ * DIFFERENT id is refused rather than silently overridden (spec wins, a
763
+ * mismatched flag is a mistake worth stopping on, never a silent swap); the
764
+ * same id from both is not a conflict. Extracted from scripts/run-u.mjs so
765
+ * the decision is testable without running the script or touching a provider.
766
+ * @param {{ specModel?: string, flagModel?: string, defaultModel: string }} args
767
+ * `specModel` — `job.model` if the signed spec carries one, else undefined.
768
+ * `flagModel` — the model id an explicit `--model <tier>` resolved to, else
769
+ * undefined (a flag that was never passed, not merely a default tier name).
770
+ * `defaultModel` — the runner's own default when neither spec nor flag says.
771
+ * @returns {{ model: string, source: 'spec'|'flag'|'default' }}
772
+ * @throws {Error} when the spec names a model and the flag names a different one
773
+ */
774
+ export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
775
+ if (specModel !== undefined) {
776
+ if (flagModel !== undefined && flagModel !== specModel) {
777
+ throw new Error(`spec names model "${specModel}" but --model resolved to "${flagModel}" — the signed spec wins; a mismatched --model flag is refused, never silently overridden. Drop --model or point it at the same model.`);
778
+ }
779
+ return { model: specModel, source: 'spec' };
780
+ }
781
+ if (flagModel !== undefined) return { model: flagModel, source: 'flag' };
782
+ return { model: defaultModel, source: 'default' };
783
+ }
package/src/planrun.js CHANGED
@@ -1363,7 +1363,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1363
1363
  * an unchanged tree is flat on its own evidence while the RUN — the thing the human
1364
1364
  * is deciding about — may have been converging when its allowance ran out. Cold, the
1365
1365
  * seed is empty and this is byte-identical to the pre-resume reader. */
1366
- const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
1366
+ // per-stage comparison direction, read straight off the signed close (2026-08-31
1367
+ // ruling): a stage absent here — every close authored before this field existed
1368
+ // included — resolves to 'down' inside createTrend, so this is additive only.
1369
+ const stageDirections = Object.fromEntries(
1370
+ stagedClose.filter((/** @type {any} */ s) => s?.direction === 'up' || s?.direction === 'down').map((/** @type {any} */ s) => [s.name, s.direction]),
1371
+ );
1372
+ const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades, directions: stageDirections });
1367
1373
  /** the run-level MONEY record, one shape wherever the wallet stops the run —
1368
1374
  * W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
1369
1375
  * ruling for TIME was "keep the grade we already have and stop"; a money cut is
@@ -3396,6 +3402,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3396
3402
  stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
3397
3403
  limit: FIX_STRIKE_LIMIT,
3398
3404
  blindCap: capRuns,
3405
+ directions: stageDirections,
3399
3406
  });
3400
3407
  fixTrend.record({ gap: post.gap ?? '' });
3401
3408
  /** ralph's `ladder` seam, filled by the trend reader instead of the step
package/src/trend.js CHANGED
@@ -212,12 +212,25 @@ const UNSTAGED = '(unstaged)';
212
212
  * grades a PREVIOUS leg of this same run already recorded, oldest first, read off
213
213
  * its own spine. Baselines only (see THE SEED above). Empty/omitted is the cold
214
214
  * path and is byte-identical to the pre-resume reader.
215
+ * @param {Record<string, 'up'|'down'>} [opts.directions] per-stage comparison
216
+ * direction, read from the signed close spec (2026-08-31 ruling, hamr's option
217
+ * A) — never inferred, never LLM-judged. 'up' = higher is better (a rate of good
218
+ * things caught); 'down' = lower is better (a count of bad things remaining, the
219
+ * pre-existing behaviour). A stage absent from this map — including every close
220
+ * authored before this field existed — resolves to 'down', so a cold spec's
221
+ * trend reading is byte-identical to before this option existed.
215
222
  */
216
- export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCap = null, seed = [] } = {}) {
223
+ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCap = null, seed = [], directions = {} } = {}) {
217
224
  /** @type {Map<string, number[]>} stage → every count it reported, in order */
218
225
  const series = new Map();
219
- /** @type {Map<string, number>} stage → the BEST (lowest) count it ever reported */
226
+ /** @type {Map<string, number>} stage → the BEST (max for 'up', min for 'down') count it ever reported */
220
227
  const best = new Map();
228
+ /** is `a` a BETTER reading than `b` for this stage, under its own declared
229
+ * direction? 'up' stages are better when higher (a caught-rate climbing);
230
+ * every other stage — including one absent from `directions` — is better
231
+ * when lower, which is the ORIGINAL, only comparison this reader ever made. */
232
+ const isBetter = (/** @type {string} */ stage, /** @type {number} */ a, /** @type {number} */ b) =>
233
+ (directions[stage] === 'up' ? a > b : a < b);
221
234
  /** the furthest stage index reached so far; null until a KNOWN stage is seen */
222
235
  let bestStageIdx = null;
223
236
  let iterations = 0;
@@ -249,7 +262,7 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
249
262
  if (!series.has(st)) series.set(st, []);
250
263
  /** @type {number[]} */ (series.get(st)).push(v);
251
264
  const b = best.get(st);
252
- if (b === undefined || v < b) best.set(st, v);
265
+ if (b === undefined || isBetter(st, v, b)) best.set(st, v);
253
266
  }
254
267
 
255
268
  return {
@@ -273,7 +286,7 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
273
286
  // ── the two comparisons, each against this stage's OWN history ──
274
287
  const priorBest = best.get(st);
275
288
  const countComparable = v !== null && priorBest !== undefined;
276
- const countImproved = countComparable && v < /** @type {number} */ (priorBest);
289
+ const countImproved = countComparable && isBetter(st, /** @type {number} */ (v), /** @type {number} */ (priorBest));
277
290
  // the ordering signal only exists between two KNOWN positions that DIFFER —
278
291
  // an unnamed stage has no position, and standing still says nothing
279
292
  const stageComparable = idx >= 0 && bestStageIdx !== null && idx !== bestStageIdx;
@@ -286,7 +299,7 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
286
299
  if (v !== null) {
287
300
  if (!series.has(st)) series.set(st, []);
288
301
  /** @type {number[]} */ (series.get(st)).push(v);
289
- if (priorBest === undefined || v < priorBest) best.set(st, v);
302
+ if (priorBest === undefined || isBetter(st, v, priorBest)) best.set(st, v);
290
303
  }
291
304
  if (idx >= 0 && (bestStageIdx === null || idx > bestStageIdx)) bestStageIdx = idx;
292
305
  if (stageImproved) advancedEver = true;
@@ -348,10 +361,12 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
348
361
  // question is the different one of whether the LAST attempt helped.
349
362
  const numeric = rows.filter((r) => r.values.length > 1);
350
363
  const render = numeric.map((r) => `${r.stage} ${r.values.join(' → ')}`).join('; ');
351
- // convergence is defined PER STAGE and per stage only: some axis is lower than
352
- // where it started. First-vs-last, because the question a halt asks is "did
353
- // this run make net progress", not "did the last attempt help".
354
- const fell = numeric.some((r) => r.values[r.values.length - 1] < r.values[0]);
364
+ // convergence is defined PER STAGE and per stage only: some axis moved toward
365
+ // its OWN declared better direction from where it started — lower for a
366
+ // 'down' stage (a count of bad things remaining), higher for an 'up' stage (a
367
+ // rate of good things caught). First-vs-last, because the question a halt asks
368
+ // is "did this run make net progress", not "did the last attempt help".
369
+ const improvedNet = numeric.some((r) => isBetter(r.stage, r.values[r.values.length - 1], r.values[0]));
355
370
 
356
371
  // No stage ever reported a comparable number: the honest answer, stated rather
357
372
  // than rounded up to either direction. A "flat" here would recommend rewriting
@@ -385,7 +400,7 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
385
400
  // Past this point a real number decided it, so motion is not consulted and is
386
401
  // not reported: a byte comparison beside a measured direction can only ever
387
402
  // contradict it, and the contradiction would be the weaker signal winning.
388
- return fell
403
+ return improvedNet
389
404
  ? {
390
405
  trend: 'converging',
391
406
  motion: null,
package/types/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { makeSpine } from "./spine.js";
2
2
  export { runPlan } from "./planrun.js";
3
3
  export { ralph, runClose, runStages, CLOSE_FAULTS } from "./ralph.js";
4
4
  export { globToPrefix, scanSecrets } from "./validate.js";
5
- export { validateJob, jobSpecHash, checkApproval, checkMenu, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from "./job.js";
5
+ export { validateJob, jobSpecHash, checkApproval, checkMenu, resolveWorkerModel, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from "./job.js";
6
6
  export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS, closeStagesOf } from "./plan.js";
7
7
  export { snapshotScope, evalExits } from "./exits.js";
8
8
  export { runJob, ACCOUNTED_ROUND_TYPES } from "./run.js";
package/types/job.d.ts CHANGED
@@ -82,6 +82,30 @@ export function jobSpecHash(job: object): string;
82
82
  * @returns {boolean}
83
83
  */
84
84
  export function checkApproval(job: object, approvals: unknown): boolean;
85
+ /**
86
+ * Pure decision: which worker model id a run actually uses (build-list #3,
87
+ * hamr's GO 2026-08-30). The signed spec's `model`, if present, is what an
88
+ * operator SIGNED — it wins outright. A `--model` runner flag naming a
89
+ * DIFFERENT id is refused rather than silently overridden (spec wins, a
90
+ * mismatched flag is a mistake worth stopping on, never a silent swap); the
91
+ * same id from both is not a conflict. Extracted from scripts/run-u.mjs so
92
+ * the decision is testable without running the script or touching a provider.
93
+ * @param {{ specModel?: string, flagModel?: string, defaultModel: string }} args
94
+ * `specModel` — `job.model` if the signed spec carries one, else undefined.
95
+ * `flagModel` — the model id an explicit `--model <tier>` resolved to, else
96
+ * undefined (a flag that was never passed, not merely a default tier name).
97
+ * `defaultModel` — the runner's own default when neither spec nor flag says.
98
+ * @returns {{ model: string, source: 'spec'|'flag'|'default' }}
99
+ * @throws {Error} when the spec names a model and the flag names a different one
100
+ */
101
+ export function resolveWorkerModel({ specModel, flagModel, defaultModel }: {
102
+ specModel?: string;
103
+ flagModel?: string;
104
+ defaultModel: string;
105
+ }): {
106
+ model: string;
107
+ source: "spec" | "flag" | "default";
108
+ };
85
109
  export const CLOSE_TYPES: readonly string[];
86
110
  /** the hierarchy: which verdict classes a close type may claim (PRD §7). Read by
87
111
  * the verdictType check — a declared verdict may not demand a class its close
package/types/trend.d.ts CHANGED
@@ -30,8 +30,15 @@ export function readGrade(gap?: string): {
30
30
  * grades a PREVIOUS leg of this same run already recorded, oldest first, read off
31
31
  * its own spine. Baselines only (see THE SEED above). Empty/omitted is the cold
32
32
  * path and is byte-identical to the pre-resume reader.
33
+ * @param {Record<string, 'up'|'down'>} [opts.directions] per-stage comparison
34
+ * direction, read from the signed close spec (2026-08-31 ruling, hamr's option
35
+ * A) — never inferred, never LLM-judged. 'up' = higher is better (a rate of good
36
+ * things caught); 'down' = lower is better (a count of bad things remaining, the
37
+ * pre-existing behaviour). A stage absent from this map — including every close
38
+ * authored before this field existed — resolves to 'down', so a cold spec's
39
+ * trend reading is byte-identical to before this option existed.
33
40
  */
34
- export function createTrend({ stageOrder, limit, blindCap, seed }?: {
41
+ export function createTrend({ stageOrder, limit, blindCap, seed, directions }?: {
35
42
  stageOrder?: string[] | undefined;
36
43
  limit?: number | undefined;
37
44
  blindCap?: number | null | undefined;
@@ -39,6 +46,7 @@ export function createTrend({ stageOrder, limit, blindCap, seed }?: {
39
46
  stage?: string | null;
40
47
  value?: number | null;
41
48
  }[] | undefined;
49
+ directions?: Record<string, "up" | "down"> | undefined;
42
50
  }): {
43
51
  /**
44
52
  * Read one RED close grade. A green never reaches here (both callers stop on