Skip to content

Build master @ bc50cd5bunreleased — 31 commits past v1.282.0Last reviewed Last changed Grade fresh (aging >90d, stale >180d)Source / History

The decide() seam ​

scripts/lib/decide.js is the one way BoB asks a small model a typed question about a state. A caller states its question and its state; the seam decides which backend answers, whether the state may leave the machine at all, and what the answer's confidence permits.

It exists because scripts/lib/jev.js had exactly one caller (scripts/warp-drive/issue-dedupe.js) and that caller knew it was talking to Jev — it phrased its question the way Jev reads one and routed on Jev's confidence scale. Briefing #2798 put this first of everything it would file, for one reason: at one call site that coupling is free, and at ten it is a migration. The seam landed before the second call site.

The contract ​

js
const { decide } = require('./decide');

const r = await decide({
  state: { new_issue: { title, body } },
  questions: { duplicate_of: { type: 'choice', instructions, criteria } },
  caller: 'groom-dedupe',
});

if (!r.decided) {
  if (r.record_required) record(r.abstention);   // a judge must say it abstained
  fallback(r.reason);
} else if (r.answers.duplicate_of.route === 'act') {
  ...
}

The question vocabulary is the contract: choice (one of a set), score (a position on ordered levels) and noul (the probability a yes/no holds), each answered with a confidence. Jev defined that vocabulary and the seam adopts it — worth saying plainly rather than pretending the seam is shapeless. A backend must speak it, and every backend's answers are validated by the same function (jev.normalizeResponse).

A decided answer:

FieldWhat it is
schemadecision/1
decidedtrue
caller, backend, modelwho asked, what answered, which version
stakes, failthe mode the call ran under, from the caller's declaration or derived from the action
stakes_sourcedeclared (the registry), explicit (the call) or classify (derived from the action)
classificationthe classify() verdict the stakes were checked against — present only when an action was passed
answers[id]{ answer, confidence, route, threshold, reason } per question
routethe weakest route across the set — one human makes the whole decision human
confidencethe lowest confidence in the set
usage, latency_mswhat it cost and how long it took

An undecided answer carries decided: false, a reason, route: 'human', record_required, and — when recording is required — an abstention record. decide() throws only on a programming error (an undeclared caller, unknown stakes, stakes lower than the action derives, a stakes_from caller that passes no action, an unknown backend, a missing fail mode). It never throws because a backend failed.

Backends ​

The backend is configuration, resolved once: opts.backend → BOB_DECIDE_BACKEND → the caller's declaration → jev.

BackendBuiltWhat it is
jevyesTypeSafe Jev through scripts/lib/jev.js, unchanged — one batched request per state, pinned model, injectable transport
deterministicyesopts.rule(state, questions) returns the same typed answers. No network, no key, no cost — the fastest backend, and the right one whenever the question is really a rule
haiku-forced-toolnoA Haiku call with a forced tool call, for questions Jev cannot express. Declared, not built — its contract is recorded in BACKENDS['haiku-forced-tool'].contract, and calling it returns not-implemented with record_required: true rather than a silent gap
offyesNever decides. The honest way to turn the seam off: every caller takes its declared fallback
node scripts/lib/decide.js backends [--json]   # every backend, which is built, which would answer
node scripts/lib/decide.js callers  [--json] [--callers <file>]   # every declared call site, with the tier a stakes_from caller derives
node scripts/lib/decide.js status   [--json]   # the resolved backend and the tiers

The caller registry ​

scripts/lib/decide-callers.json is the declaration of every call site. A caller absent from it cannot call — the seam refuses rather than sending an undeclared state. Adding a call site is therefore a declaration, not a keystroke.

json
"groom-dedupe": {
  "purpose": "Which older open issue asks for the same work as a new one?",
  "allow": ["new_issue.title", "new_issue.body"],
  "stakes": "mutating",
  "fail": "open",
  "why_fail": "A gate. Failing open yields one extra issue, which a person can close."
}

Declared callers ​

CallerAsksMay sendStakesFailWhy that fail mode
groom-dedupeWhich older open issue asks for the same work as a new one? (scripts/warp-drive/issue-dedupe.js, #2681). The issue's declared parent and a cap's listed children are never offered as candidates (#2985).new_issue.title, new_issue.bodymutatingopenA gate: failing open yields one extra issue, which a person can close; failing closed would stall grooming.
brief-next-actionWhich of a brief's candidate next actions comes first? (scripts/brief/, #2818)brief.title, .summary, .priority, .age_days, .state, .actionsmutatingvisibleA judge: an abstention that read as approval would silently degrade the surface it sharpens.
decompose-testabilityIs each acceptance criterion on a drafted child observable — actor, action, result? (scripts/decompose/testability.js, #2977)child.title, child.acceptance_criteriamutatingvisibleA judge: "looks testable" by default would degrade every child filed with no signal at all.

decompose-testability is the decomposition machine's (#2973) testability gate. One noul question per criterion goes out in one request; the per-criterion answer, confidence, route, backend and model are recorded on the run state. A criterion answered not observable, or routed human, blocks the child from filed until it is reworded and re-judged, or a human override is written on the child's issue body (a <!-- decide:override decompose-testability --> block naming who and why). The gate reads overrides only from the body, and it refuses an override from the run's own identity, so the machine cannot override itself or do it silently. An undecided answer does not block: the child may be filed, but it is marked with a <!-- decide:abstained decompose-testability --> block giving the reason, so abstentions can be counted on the artifacts. The deterministic backend's rule (observableRule) passes actor + action + result and fails a bare artefact noun, which is enough for tests and offline runs. Coverage: tests/test-testability.js.

Egress: the state leaves the machine ​

allow is the list of state fields that may be sent. Every leaf of the state is a dotted path; array elements share their parent's path, so candidates[].title is candidates.title and one entry covers the whole list. An entry covers itself and everything beneath it. * covers the state whole and has to be written down to mean it.

A state carrying an undeclared field is refused — reported on stderr, returned as reason: 'egress-refused' with the offending paths named, and record_required whatever the caller's fail mode. Nothing is sent. It is never quietly trimmed, because a silently trimmed state answers a different question than the one the caller asked.

The static twin: check-decide-lint (#2982) ​

The runtime refusal protects the machine while a call runs. It cannot see a script that never calls the seam, and it only catches an undeclared key when a run happens to build one. scripts/checks/decide-lint.js, run by make check as check-decide-lint, catches both before a run:

OffenceWhat it catches
bypassa require(...jev.js), or jev.evaluate( / jev.batch( on any name bound to it, outside scripts/lib/decide.js and scripts/lib/jev.js
undeclared-keya decide() call whose state is a literal object, or the one object a same-file builder function returns, carrying a key outside that caller's allow — checked with the seam's own coverage rule (refusedPaths)
undeclared-callera decide() call naming a caller absent from decide-callers.json

Each offence is named with its file and line, and the lint exits 1. A call whose state or caller it cannot resolve (a variable, a spread, a builder from another file) is listed as unverified and counted on the summary line (4 decide() call(s) — 4 verified, 0 unverified). It does not fail the build and it is never passed silently: keep a call site's state a literal or a same-file builder so the lint can read it.

It scans scripts/, hooks/, bin/, apps/, registry/, skills/ and templates/; tests/ is not scanned. Comments and strings are not code. A deliberate exception goes in scripts/checks/decide-allow.json, in the abs-path-allow.json shape (path, optional pattern and rule, a required why). Today it holds one entry: the llm-tiering skill's Jev example, which measures the backend itself.

node scripts/checks/decide-lint.js           # report; exit 1 on an offence
node scripts/checks/decide-lint.js --json    # decide-lint/1 document
node scripts/checks/decide-lint.js --root DIR [--callers FILE] [--allow FILE]

Fail-open is the caller's choice, never a default ​

fail has no default and the seam refuses a caller that has not declared one.

  • open — a gate. An undecided answer means carry on. A missed duplicate costs one extra issue, which a person can close.
  • visible — a judge. An undecided answer is an artifact: record_required: true, an abstention record naming the caller, the backend, the reason and the time, and abstentionNote(result) for the one line to write onto the artifact.

This is #2798's first blind spot, made structural. A judge that fails open to "looks fine" degrades quality with no signal at all — the worst failure mode there is, because it is indistinguishable from success. "The machine had no opinion" must never read as "the machine approved it".

Routing ​

Routing happens once, here, so no call site re-implements the tiers. The tiers are jev.js's — one declaration, not two.

StakesActs atOtherwise
read-only≥ 0.5below the 0.5 floor → human
mutating≥ 0.80.5–0.8 → confirm; below the floor → human
destructiveneveralways human, whatever the confidence

A caller may raise its own bar with thresholds (the dedupe gate ships at 0.85) but cannot lower the floor. Across several questions the weakest route wins.

Stakes from reversibility (#2981) ​

Two engines grade a decision on two different axes:

  • Reversibility — scripts/warp-drive/decision-engine.js classify() (Reversibility Decision Engine): can this be undone? It answers one-way or two-way, and anything it does not recognise is one-way.
  • Confidence — jev.route(), through this seam: is the model sure enough for the stakes?

Reversibility picks the threshold, and confidence has to clear it. A caller passes the action it is deciding about (the descriptor classify() reads: { description, command } or a string), and the seam derives the stakes:

classify() verdictStakesRoute
two-way, read_only: true (a query, a report, a dry run)read-onlyacts at ≥ 0.5
two-waymutatingacts at ≥ 0.8, else confirm
one-waydestructivealways human
unknown (one-way by the fail-safe)destructivealways human
js
const r = await decide({ caller: 'my-caller', state, questions,
  action: { description: 'close the duplicate issue' } });
// r.stakes === 'mutating', r.stakes_source === 'classify', r.classification.door === 'two-way'

The thresholds stay in one place, jev.js TIERS. The seam maps a verdict to a tier name and never holds a number of its own.

Read-only is the narrow verdict. It is a two-way category checked after every category that changes something. It never matches when a mutating verb (delete, write, create, update, apply, deploy, …) appears beside the read verb, so an action such as "list the stale records and delete them" is never graded read-only. Getting it wrong raises the stakes, never lowers them.

A written stakes may raise the tier, never lower it. When an action is passed, a stakes written in the call or declared in the registry that is lower than the derived one is refused as a programming error. A higher one wins (stakes_source names where the stakes came from). A call with no action keeps the caller's declared stakes unchanged, so every existing caller behaves as before.

In the registry, a caller declares "stakes_from": "classify" instead of a literal. It may also declare a standing "action", which is used when a call passes none:

json
"my-caller": {
  "purpose": "…",
  "allow": ["…"],
  "stakes_from": "classify",
  "action": { "description": "query the open issues" },
  "fail": "open"
}

decide.js callers prints the tier that standing action derives, for example [stakes read-only (from classify: two-way, read-only), fail open]. With no standing action it prints derived per call, and the JSON carries derived_stakes: null. A stakes_from caller that passes no action and declares none is refused.

Testing ​

tests/test-decide.js (90 assertions) runs with no key and no network: every backend is the deterministic rule or a fake transport. It pins the answer document, backend resolution, the unbuilt backend's declaration, routing and the weakest-route rule, the refusal of an undeclared fail mode / stakes / caller, that a refused egress sends nothing, the CLI, and stakes derived from reversibility: each verdict's tier, the refusal of a lower written stakes, the fail-safe route, and the callers output for a stakes_from caller.

tests/test-decide-lint.js drives the lint against a clean and a dirty fixture tree (tests/fixtures/decide-lint/) and against this repository.

tests/test-issue-dedupe.js is the migration proof: its 39 assertions pass unchanged across the move from jev.evaluate() to decide().

See also ​