Spec: Convergence Score¶
Status: Implemented in task mode (solumbe converge) + convergence_score MCP tool, including exact change-subject receipt v2.
Depends on: src/lib/impact.js (generateImpact, validateAgainstDiff),
src/lib/risk-paths.js (classifyPath, isSecretPath, RISK_FLAGS),
src/lib/tools.js (runCommand), node:crypto.
Implementation: src/lib/converge.js (generateConvergence, makeReceipt),
CLI solumbe converge, MCP convergence_score, tests in tests/converge.test.js.
1. Summary¶
A deterministic 0–100 score for the distance between a stated task (intent) and the actual git diff (execution), the buildable core of the convergence argument (README.md). It is not a proof of correctness; it is a reproducible measurement, computed out-of-band where the agent cannot fake it.
2. Why this is low-risk to build¶
Every input already exists; convergence is a composition layer, not new analysis:
| Signal | Source (already in repo) |
|---|---|
| Predicted owner files for the task (intent) | generateImpact(query).data.topFiles in src/lib/impact.js |
| Predicted-vs-actual diff comparison | validateAgainstDiff (already inside generateImpact when diffBase is set): confirmedDirect, confirmedRelated, unconfirmedCandidates, missedChangedFiles |
| Risk weight of a drifted path | classifyPath / isSecretPath / RISK_FLAGS in src/lib/risk-paths.js |
| Exact change subject for receipt v2 | Staged: resolved base + parent commit + git write-tree; head: resolved base + head commit + its tree; PR: GitHub repository/number + exact base/head OIDs |
| Precedent for a 0–100 composite + receipt-style evidence | generateAxScore (src/lib/ax.js), review_verdict |
3. Score model¶
Three sub-scores, each 0–100, blended with config-overridable weights (defaults below).
Convergence = 0.45 * Coverage // did the intent happen?
+ 0.35 * Scope // did only the intent happen?
+ 0.20 * RiskAlignment // did drift land somewhere dangerous?
3.1 Coverage (did intent happen?)¶
predictedDirect = confirmedDirect + unconfirmedCandidates
Coverage = predictedDirect > 0 ? 100 * confirmedDirect / predictedDirect : 0
Share of the task's predicted owner files that were actually changed. A task that grounds to
no predicted file is unmeasurable: the result is inconclusive (3.4), with no Coverage at all.
Owners are predicted from the request's words first: the paths, symbols, exports and routes
that match it. When no file clears that bar, the import graph is asked. A file is then an
owner when a word of the request is its name (util for src/lib/util.js) or a symbol it
exports, no other source file shares that name or export, and it has at least one import edge,
so it is a module the repository uses and not a stray file. Candidates are ordered by how many
files import them, and the lexical score only breaks ties. Words that say what to do or name
code in general (refactor, index, test) anchor nothing. The file's reasons say
"grounded in the import graph" and which word and edge count did it.
3.2 Scope (did only intent happen?)¶
onTask = confirmedDirect + confirmedRelated + inferredRelated
Scope = changedFiles > 0 ? 100 * onTask / changedFiles : 0
Share of the diff the task anticipated (directly, as a related dependency, or as the
confirmed owners' own fan-out). missedChangedFiles, files that changed but nothing in the
task predicted, are scope drift. An empty diff converges on nothing, so Scope is 0.
inferredRelated moves a changed file out of drift (or out of the advisory bucket) under
one of six named rules, each anchored on a file the diff already confirmed:
owner-sibling: a file the change adds in the same directory as a confirmed required owner. It must be a mapped source file, must not be a secret path, and must carry no risk flag the owner lacks. Modified siblings, files in subdirectories, and owners at the repository root anchor nothing.owner-test: a test, added or modified, whose file name names a confirmed file or an inferred sibling:PersonDialog.test.tsxforPersonDialog.tsx,SmartTable.selection.test.tsxforSmartTable.tsx,test_converge.pyforconverge.py. Generic stems (index,utils,types, …) must sit beside the file or in a__tests__/test/testsdirectory directly under it. A suffix after a hyphen or underscore also names a non-generic file:mcp-dispatch.test.jsformcp.js.owner-changelog: the changelog (CHANGELOG.md,CHANGES.md,HISTORY.md, release notes), which records the change.owner-test-data: fixture or eval data (evals/fixtures/…,evals/corpus.json,testdata/…), the input the change's tests run on.
The last two need at least one confirmed owner; a diff that is only a changelog or only fixtures stays drift.
Three rules read the lines the change adds (a zero-context diff against the base, or the whole file when an untracked file is scored), so they hold for what this change did:
owner-testalso covers a test whose added lines import a confirmed file, whatever its name.owner-import: a source file whose added lines import a confirmed file, by a relative specifier or a root alias (@/utils/analytics,~/lib/x,#/lib/x, resolved from the repository root orsrc/), carrying no risk flag that file lacks. The change wired them together, as when a helper moves into a shared module and its callers import it there, or a feature calls a confirmed module from the screens it touches.owner-doc: a doc whose added lines name a confirmed or inferred file by path or file name. Generic file names (index.ts) name nothing, and the change's code is preferred over its fixtures as the anchor.
Each entry records its rule and anchor, so the inference is visible rather than silent.
3.3 Risk alignment (is the drift dangerous?)¶
Each unrequested changed file is penalised by its worst risk flag, so a drifted edit to a secret/auth/money path tanks the score and a drifted README barely moves it.
secret 30 · auth/security 25 · money flow 25 · data model 15 · contract 15
· request surface 15 · configuration 10 · default 5
3.4 Bands¶
aligned ≥ 80 · partial ≥ 50 · drift < 50.
A fourth outcome carries no score. When the task predicts no owner file (drivers.grounded is
false), there is nothing to measure the diff against: coverage, scope and drift are all
relative to a prediction that does not exist. The result is band: "inconclusive" with
convergence: null and every sub-score null. Before engine 0.6.0 such a task scored about
20 (risk alignment alone) and banded as drift, the same for a correct change and an
unrelated one. The drivers still list the changed files, as "changed, not predicted" rather
than as drift, and name any that sit on a risk-sensitive path. The receipt is computed as
usual, so an inconclusive result can still be recomputed and verified.
3.5 What is scored¶
| Mode | Changed files | Receipt |
|---|---|---|
| Working tree (default) | git diff $(git merge-base <base> HEAD): tracked files, staged or not, from where the branch started, so commits that landed on the base afterwards are not scored. Untracked files are listed under untracked and not scored unless --include-untracked is passed. |
v1 |
--head <ref> |
Exactly base..head as a direct tree diff (no merge base). Uncommitted and untracked files play no part. |
v2, git-commit |
--staged |
Exactly base..index via git write-tree. |
v2, git-index |
| GitHub PR (gate only) | merge-base(base, head)..head, checked against GitHub's file list. |
v2, github-pr |
--head and --staged cannot be combined. To score a staged or committed change whose base
has moved on, pass the merge base as --base (git merge-base origin/main HEAD); --head and
--staged deliberately do not compute one, so the subject is exactly the two trees named and
its receipt re-verifies in a shallow clone. Working-tree mode has no tree-bound receipt and
computes the merge base itself.
4. Recomputable receipt¶
The video's "tamper-evident attestation": a hash anyone can regenerate from the same inputs and compare to the one recorded for a change.
inputsHash = sha256( canonical({ engine, task, base, commit, convergence,
subScores, changedFiles, confirmedDirect,
confirmedRelated, unconfirmedCandidates,
missedChangedFiles, inferredRelated? }) )
id = "rcpt_" + inputsHash[0:12]
The canonical payload excludes generatedAt and sorts every file list, so the
receipt is identity, not timestamp or ordering, the property that makes "recompute and
compare" meaningful in CI. inferredRelated (file paths only) is present only when a file
was inferred into scope, so a payload without inference hashes exactly as before.
engine is convergenceEngineVersion. Engine 0.2.0 added owner-adjacent inference and
stopped scoring untracked files by default; both change scores, so a receipt issued by
0.1.0 does not recompute under 0.2.0. Engine 0.3.0 added suffixed test names, the
changelog and test data to that inference, so a 0.2.0 receipt does not recompute under
0.3.0 either. Engine 0.4.0 added the rules that read added lines (owner-import,
owner-doc, and tests by import), so a 0.3.0 receipt does not recompute under 0.4.0.
Engine 0.5.0 follows root-alias imports in those rules, so a 0.4.0 receipt does not
recompute under 0.5.0. Engine 0.6.0 reports an ungrounded task as inconclusive with no
score, so a 0.5.0 receipt does not recompute under 0.6.0. Re-issue it with the current
engine.
Calls without an exact subject retain the byte-for-byte v1 canonical payload and receipt shape for compatibility. A subject-bound receipt uses v2 and adds both of these fields to the canonical payload:
receiptVersion = 2
subject = canonical({ kind, repository?, number?, baseSha,
parentSha?, headSha?, treeSha? })
The three valid subject shapes are:
git-commit: resolved base commit, resolved head commit, and the head commit's tree SHA. Changed paths are captured frombaseSha..treeShaand the scoring map is built from raw blobs in that tree, with the same filter, replace-ref, rename, and size controls asgit-indexbelow. The receipt'scommitis the head commit. A supplied subject is rechecked: the head must still resolve to the recorded tree, and the diff files must match.git-index: resolved base commit, current parent commit, and the tree SHA produced bygit write-tree. Changed paths are captured frombaseSha..treeSha, so the file list and subject come from the same immutable tree. The task-scoring code map is also built from raw blobs in that tree, so unstaged source edits cannot change the receipt and configured checkout filters cannot execute during scoring. Blobs are analyzed in bounded batches, and receipt generation fails closed above 5,000 source files or 64 MiB of eligible source. Git paths use NUL-delimited capture so legal whitespace and newline characters remain exact. Git diff commands disable local replacement refs, force changed gitlinks to remain visible, and fix rename detection at 50% with a finite 1,000-candidate limit. These controls prevent repository-local Git configuration from changing the comparison's meaning.github-pr: target repository, PR number, and the exactbaseRefOid/headRefOidreturned by GitHub. Exact receipt issuance fails closed if GitHub'schangedFilestotal does not equal the returned file list, the localmerge-base(baseSha, headSha)..headShadiff does not match that list, or the local analysis checkout is not the exact, clean PR head. Its scoring map is built fromheadRefOid.
rcpt_ plus 12 hex characters is a display handle. Exact-subject enforcement compares the
full 64-character inputsHash. The receipt is hashed but not yet cryptographically signed.
5. Output schema¶
{
"ok": true,
"convergenceEngineVersion": "0.6.0",
"task": "add Stripe refunds",
"base": "origin/main",
"head": "HEAD",
"subject": {
"kind": "git-commit",
"baseSha": "a77d38a...",
"headSha": "b88e49b...",
"treeSha": "c99f50c..."
},
"repo": { "name": "shop-api", "root": "/..." },
"convergence": 72,
"band": "partial",
"subScores": { "coverage": 80, "scope": 70, "riskAlignment": 60 },
"drivers": {
"changedFiles": 4, "predictedDirect": 5,
"confirmedDirect": ["..."], "confirmedRelated": ["..."],
"inferredRelated": [{ "file": "src/refunds/reason.js", "rule": "owner-sibling", "anchor": "src/refunds/refund.js" }],
"unconfirmedCandidates": ["..."], "missedChangedFiles": ["..."],
"advisoryChangedFiles": ["..."],
"grounded": true,
"riskyDrift": [{ "file": "src/auth/login.js", "weight": 25, "flags": ["auth/security"] }]
},
"recommendations": ["..."],
"receipt": {
"id": "rcpt_f2af2b4b6d5b",
"algorithm": "sha256",
"commit": "b88e49b...",
"inputsHash": "...",
"receiptVersion": 2,
"subject": { "kind": "git-commit", "baseSha": "a77d38a...", "headSha": "b88e49b...", "treeSha": "c99f50c..." }
}
}
In working-tree mode there is no subject, and the result carries
"untracked": { "included": false, "count": 2, "files": ["dump.rdb", "..."] } (at most 25
paths listed).
6. Surface¶
solumbe converge "<task>" --base origin/main # task mode, cwd, tracked changes
solumbe converge <repo> "<task>" --base HEAD~1 --json # explicit repo, machine-readable
solumbe converge "<task>" --base HEAD~1 --head HEAD --json # exact base..head commit subject
solumbe converge "<task>" --base HEAD --staged --json # exact Git-index subject
solumbe converge "<task>" --base origin/main --include-untracked # also score untracked files
MCP: convergence_score (requires query + base, accepts head, staged, and
includeUntracked), mirroring
agent_experience. This is the deliberate 13th tool on a surface guarded by three count
tests + the migration doc. Because staged capture uses git write-tree, it may write Git
object or index-cache metadata without changing source files; the MCP tool is conservatively
not annotated read-only.
7. Tests¶
Pure pieces are unit-tested (tests/converge.test.js): frozen v1 compatibility, v2 subject
canonicalisation and validation (including git-commit), sensitivity to tree/base/head
changes, owner-adjacent inference rules and their guards, and band thresholds. Head fixtures
verify that dirty and untracked files are excluded, that the receipt survives later commits
and edits, that forged commit subjects are refused, and that working-tree mode lists rather
than scores untracked files. Staged and PR fixtures verify tree capture, immutable-tree
scoring, filter and replace-ref isolation, rename-configuration independence, NUL-safe paths,
merge-base PR scope, complete file scope, exact head matching, CLI, MCP, and Gate
propagation.
8. Gate integration¶
Convergence can now be made load-bearing by passing a task plus either a minimum score, a receipt, or both to the local/PR gate:
solumbe gate . --base origin/main --request "update the greeting" --min-convergence 80
solumbe converge . "update the greeting" --base origin/main --staged --json > .solumbe/convergence.json
solumbe gate . --base origin/main --staged --request "update the greeting" --receipt .solumbe/convergence.json
solumbe converge . "update the greeting" --base HEAD~1 --head HEAD --json > .solumbe/convergence.json
solumbe gate . --base HEAD~1 --head HEAD --request "update the greeting" --receipt .solumbe/convergence.json
The gate recomputes the score from the selected diff and fails when the score is
below the requested floor or when the supplied receipt does not match the current task,
base, and exact change subject. Subject-bound v2 enforcement requires the full inputs hash;
JSON receipt files are accepted and supply that hash automatically. A receipt verifies only
in the mode that produced it: --head for a git-commit receipt, --staged for a
git-index receipt, --pr for a github-pr receipt. When a JSON receipt's subject names a
different mode or head than the gate measured, the failure says which to rerun with rather
than reporting a bare hash mismatch. With --head, the gate's changed-path, risk, secret, and
convergence checks all read the head commit's tree. Receipt enforcement is opt-in so existing
gate verdicts remain backward-compatible.
An inconclusive result has no score to hold against a floor. With a --min-convergence
above 0 the Convergence check reports WARN and says the request did not ground; it does
not report FAIL, because the failure would say nothing about the change. A floor of 0 asks
for no score and passes, and a receipt that does not match fails whether or not the task
grounded. A pipeline that blocks only on FAIL therefore lets an ungrounded request through
with a warning: block on WARN as well, or name the files or symbols in the request, where
that matters.
This is an exact convergence receipt, not yet a complete Gate attestation. Release checks,
optional local analyzers, base-branch CODEOWNERS, GitHub review state, and signer identity are
not all bound into this envelope yet. In staged mode, solumbe gate --run-validation can produce
a separate validation receipt: a versioned solumbe.gate.json plan is read from the selected base
commit, run against an isolated materialisation of the exact staged tree, and binds command
outcomes plus output hashes without retaining raw output. A linked local node_modules directory,
when present, is reported as not attested. The Gate still reports every remaining working-tree
boundary explicitly.
9. Scope contract¶
The score above predicts the owner files after the edit, from the tree the edit produced. A
scope contract makes the prediction before the edit and freezes it, so the gate can say
"touched src/auth/session.ts, which was never declared" rather than "78/100". The score
stays as secondary evidence.
solumbe declare . "update the greeting message" # writes .solumbe/intent.json
# ... make the change ...
solumbe gate . --staged --base HEAD --intent .solumbe/intent.json
solumbe amend src/auth/session.ts --reason "the greeting now outlives the session"
Over MCP no tool is added: change_impact with declare: true returns the same record under
intent and writes nothing, change_impact with intent and amend: { files, reason }
returns the amended record, and review_gate, review_verdict and convergence_score take
it as intent (the record, its JSON, or a path).
The record (solumbe.intent/v1) holds the request, the commit it was declared at, the
impact engine version, the declared files (owners and supporting, the required owners and
predictable supporting files solumbe impact ranks for the request) and
dirtyAtDeclaration, the tracked files that already differed from HEAD, so a contract
written after the edit began says so. It also holds what the request's own words scope:
terms: the words of the request that few tracked paths carry (at most 20 paths, or 3% of the repository if that is more).hubspotin an events platform is on a dozen paths and is a term;eventis on hundreds and is not. A word no path carries yet is kept, since it names what the change adds. Words are compared in the singular, sofeesmeetsfee-calculator.ts. Three things are never terms: a tree name (src,lib,tests), a file extension (ts), and the parts of a tracked path written out in the request. "update greet in src/index.ts" names that one file, which the prediction holds, and scopes neithersrcnor everyindex.modules: the directories whose whole name is a word of the request:src/userfor "user: …", notsrc/user-settings. A directory holding more than a fifth of the repository, a tree name (src,lib,tests, …) or a dot-directory names nothing, and a request that names more than six directories names none.
contractHash = sha256( canonical({ schema, request, head, impactEngineVersion,
owners, supporting, dirtyAtDeclaration,
terms, modules }) )
amendment.hash = sha256( prevHash + canonical({ seq, files, reason }) )
terms and modules enter the hash only when the record carries them, so a record declared
before they existed verifies as it did.
prevHash is the previous amendment's hash, or contractHash for the first. Editing the
request, a declared file, or any amendment's files or reason breaks the chain, and the gate
refuses a record that does not verify. declaredAt and amendedAt are outside the hashes:
declaring the same request on the same tree gives the same contract.
The Scope contract check runs when the gate is given an intent. Each changed file is one
of four things:
| Bucket | Meaning |
|---|---|
| declared | An owner or supporting file in the record. |
| amended | Named in an amendment, which carries its reason. |
| implied | Placed in scope by one of the rules in 3.2, anchored on a declared or amended file (its test, a new file beside it, a file whose added lines import it, the changelog entry), or by one of the declaration rules below. |
| undeclared | None of the above. The check fails and names the file, with its risk flags when it sits on a risk-sensitive path. |
A declared owner that was not changed is listed and does not fail the check. A gate given a
--request other than the declared one fails, since it would be holding the change against a
different promise. With an intent, --min-convergence and --receipt measure against the
declared files too, and the convergence receipt carries the contract hash, so it names the
contract it was scored under. A request that predicts no owner file declares no file: a
changed file is then in scope only through the request's own words (the first three rules
below) or an amendment, which is the explicit way to name files by path.
What a declaration implies. A real change is wider than the files a request predicts: the controller beside the service, the feature flag, the workflow step, the doc. Under a contract, after the rules in 3.2, seven more rules place a changed file in scope. They run in this order, and each implied file is reported with its rule and anchor:
| Rule | A changed file is in scope when |
|---|---|
request-word |
its path, without the extension, carries one of the record's terms: docs/hubspot-crm-sync.md under "hubspot: …". |
request-module |
it is under one of the record's modules: src/user/dto/me.dto.ts under "user: …". |
request-config |
it is a configuration or data file (JSON, YAML, TOML, INI, a dotfile, a snapshot, a shell or .mjs/.cjs script) and the lines the change adds to it carry one of the terms: the flag the feature ships behind, the workflow step that runs it. |
owner-module |
it sits in, or under, the directory of a declared owner, changed or not. A directory at the repository root or one level down (src) is a tree and anchors nothing. |
scope-import |
it imports, or is imported by, a changed source file already in scope, and the imported side has at most five importers. An edge into a module half the repository imports relates nothing. |
scope-test |
it is a test that imports, or is named for, a source file in scope. |
scope-doc |
it is documentation, or a template such as .env.example, and at least one source file is in scope. Documentation alone joins nothing. |
scope-import and scope-test repeat until nothing more joins, so a chain of imports from a
declared file is followed through the change. Import edges come from the code map of the tree
under review, and only edges between changed files count.
Two limits keep the rules from waving a change through:
- A secret path is never implied.
owner-moduleandscope-importdo not bring in a file on an authentication or payment path unless the anchor is on one too. A payment service beside a declared mailer stays undeclared and needs an amendment. The three request rules carry no such limit: the request named the file.
The request rules take the request at its word, and cannot tell a module named on purpose
from one a sentence happens to mention: "allow one ticket type per guest" names src/types
and src/guests as surely as "guests: …" does. A change inside a directory or to
a path that shares a distinctive word with the request is in scope by definition. What the
rules hold out is the change that shares none.
These rules widen what a declaration covers and do not change the score without one: a
converge or gate run with no intent applies only the rules in 3.2.
Measured on a real repository. bashbop-api (a NestJS service, about 1,400 tracked
files) was replayed commit by commit: the intent is declared from the commit's subject at its
parent, the commit is gated against it, and then one unrelated change at a time is planted on
top (an authentication service, a payment service, two plain services, a compose file, a
workflow) and gated again. A plant counts as caught when the check fails and names it, and a
plant whose path or module shares a word with the request is skipped, since the rules place
it in scope by definition.
| Commits that pass | Changed files undeclared | Planted changes caught | |
|---|---|---|---|
| Rules of 3.2 only, 30 most recent commits | 3 of 30 | 153 of 199 | not measured |
| With the declaration rules, the same 30 (the rules were tuned on these) | 16 of 30 | 36 of 199 | 156 of 157 |
| Rules of 3.2 only, the 30 commits before those | 5 of 30 | 172 of 219 | 143 of 143 |
| With the declaration rules, the 30 commits before those (held out, run once) | 15 of 30 | 71 of 219 | 142 of 142 |
The held-out commits were measured once. The repository's own tests then showed that a
request naming a file turned src and ts into terms, so tree names, extensions and written
paths were excluded, and both sets were measured again: every figure above was unchanged.
The one plant that passed was a payment service the declaration itself had listed as a supporting file for an unrelated request: a wrong prediction, which no rule here corrects.
About half of real commits still fail, and by design. Their undeclared files are a database
schema or migration, a feature-flag snapshot whose added lines do not carry the request's
words, a shared constants or error-dictionary file, or a module the subject line never named
(a change to how emails format dates that also edits the user and scheduled-task modules).
Each is a file a reviewer should see a reason for, and solumbe amend records one. The figures are
from one repository with conventional scope: subject commit messages; a repository with
terser subjects will declare less.
What the contract does not prove: that the declared files were the right ones, or that the code in them is correct. An agent can amend any file in; the amendment and its reason are what a reviewer reads.
10. Open questions¶
- Weights & risk penalties: defaults are placeholders; calibrate against known-good and
known-drifted PRs before locking, then move into
config.js. - Calibration: tune the score weights and risk penalties against known-good and known-drifted PRs before making a default floor part of a policy profile.