Driving Merge Desk over HTTP
Everything the page does with a model, you can do from a script. One endpoint does the work; the
rest is authentication and polling. The app takes the conflicted material as one string, a
task field naming which of three lanes to run over it, and returns a single JSON
envelope whose body shape depends on that lane.
Read one thing before anything else: Merge Desk never runs git. It
does not clone, check out, stage, commit or push, and it has no access to your repository. It reads
the text you send and answers about that text. The commands it emits are for you to read and run
yourself.
https://api.skillsafe.ai/v1/app-api
Every request carries Authorization: Bearer <token>. The token is scoped to
this app when it is minted, so no slug header is needed on later calls -
POST /guest is the one call that names the slug, in its JSON body. Get a token from
the token page without opening a developer console.
The response envelope
Every endpoint returns the same wrapper. Success carries data; failure carries
error. Nothing returns a bare value, so a client can branch on the presence of
error alone. The model's own answer is a JSON string nested inside that wrapper, at
data.output.output - two layers, and the inner one has to be parsed.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "validation_error", "message": "...", "details": { ... }}}
Error codes
| Code | HTTP | What it means | What to do |
|---|---|---|---|
| unauthorized | 401 | Missing, malformed or expired token. | Mint a new one. Guest tokens expire; personal tokens outlive them. A guest token is also refused for a metered run. |
| payment_required | 402 | Balance below the run's minimum, or below what the hold needs. | Call POST /estimate first and compare min_credits against GET /me. The page disables its own button rather than submitting into this. |
| not_found | 404 | Unknown job id, or a job that belongs to another subject. | Check the id you polled. A 404 from GET /jobs/{id} is not a transient error; do not retry it in a loop. |
| validation_error | 422 | The input did not match the app's shape. | Read error.details; it names the offending field. The commonest cause is an empty or absent conflicted. |
| rate_limited | 429 | Too many requests. | Back off and retry; never tight-loop. Polling GET /jobs/{id} faster than once a second will get you here. |
| internal_error | 5xx | The platform failed, not your input. | Retry once with the same Idempotency-Key. That is exactly what the key is for: a retry cannot double-bill. |
| sponsor_exhausted | 402 | Arrives as error.details.reason, not as error.code. The app's free sponsored allowance for the day is used up. | Switch to a personal token with credits. Signing in is what the page does here; a script should treat it as terminal for the day. |
A job that reaches a terminal failure does not arrive as an HTTP error. It arrives
as a 200 with data.status set to failed, or - on
POST /run-stream - as an SSE event: error frame. Branch on
status, not only on the HTTP code.
The task field comes first
Merge Desk is a three-lane app over one paste. Every run must name its lane in
task, because the lanes share one system prompt and one model and are routed
by that field alone. If task is missing or is not one of the three lane ids, the model
picks the closest lane, answers that lane's contract, and says so by setting
lane_inferred to true in its reply. That is a fallback for a malformed
request, not a feature to lean on: check the flag on every response and treat
true as a bug in your client.
task | What it does | Extra input | artifact.kind |
|---|---|---|---|
| triage | Decides, for every hunk and before anyone edits anything, what each side was trying to do and how the hunk should be settled. | — | none |
| resolve | Produces the merged text hunk by hunk, naming and justifying every line from either side that did not survive. | — | text |
| review | Reviews a resolution you have not pushed yet: did this merge lose anything, and is the result coherent? | resolution | markdown |
artifact.kind above is what each lane normally emits, not a guarantee.
kind is one of none, text or markdown, and it is
forced back to none whenever content is empty - so
artifact.kind != "none" is a reliable test for "there is a file here".
The lanes are a pipeline, and the reply tells you where it goes next:
next_lane.lane is normally resolve after a triage,
review after a resolve, and resolve again after a review that is
rejecting the resolution. It is "" when there is nothing sensible to run next.
Input fields
The request body is the input object itself, flat, with no wrapper. The same object is accepted by
POST /estimate, POST /run and POST /run-stream.
| Field | Type | Required | Notes |
|---|---|---|---|
| task | string | all lanes | One of triage, resolve, review. See above - this is the routing field. |
| conflicted | string | all lanes | The conflicted material as git left it, markers intact. Two-way (<<<<<<< / ======= / >>>>>>>) and diff3 (|||||||) both parse. Several files may be concatenated with # file: path/to/file marker lines; a paste carrying diff --git or +++ b/path headers is split on those instead. The page refuses anything under 24 characters. |
| resolution | string | review only | Your proposed merged text, markers removed - the file as you intend to commit it. Ignored by the other two lanes; the page hides the box for them. A review with an empty resolution is answered with posture: "blocked", which is a wasted run. |
| strategy_hint | string | all lanes | One of prefer-neither (the default), prefer-ours, prefer-theirs. A tie-breaker for genuinely equivalent hunks only. It never licenses dropping a substantive change from the other side; when it would, the model ignores the hint and says so in body.strategy_note. Send prefer-neither rather than omitting the field - it is part of the idempotency key. |
| branch_context | string | no | One or two sentences on what each branch was doing and which direction the codebase is going. Optional, and it changes the answer more than anything else here: without it the model can classify a hunk but cannot recover intent. The page truncates it to 2000 characters. |
| prescan | object | no, but send it | The deterministic facts the model must reconcile: {style, counts, files, hunks, flags}. See the next section. Optional for an API caller because the parser that produces it runs only in the browser - but the model is instructed to return exactly one coverage_check entry per flag id it was sent, and that reconciliation is the main way you can check the answer rather than trust it. |
| resolution_check | object | review only, no | The deterministic verdict on your resolution, so the review lane has to reconcile numbers rather than re-derive them by eye. Keys listed below. |
| clip_note | string | no | Tells the model the input was truncated so it works around the gap instead of inventing what was cut. The page sends this whenever it clips: it removes unconflicted context from conflicted above 56000 characters, cuts resolution from the middle above 30000, and says in the note how many characters went and whether any later hunks were dropped entirely. |
| retry_note | string | no | Sent only on an automatic re-ask after a reply that did not parse. It quotes the parse error and restates the contract; the model answers the same lane on the same input and returns only the JSON object. The page reuses a key derived from the same input with the attempt number appended, so the retry cannot double-bill. |
The prescan contract
The browser app runs a deterministic conflict parser before every run and passes its findings in
prescan. Five keys, and each has a job:
| Key | Type | What it holds |
|---|---|---|
| style | string | merge, diff3 or mixed - which marker style the paste uses overall. This matters: the merge base only exists in diff3, and the model is forbidden from reasoning about a base it was not given. |
| counts | object | {files, hunks, conflictLines, ourLines, theirLines, certain, truncated, flags, inputLines}. The reply's file_count and hunk_count must agree with counts.files and counts.hunks unless a finding explains the disagreement. certain is how many hunks the parser could settle on its own. |
| files | array | One entry per parsed file: {path, hunks, lockfile, generated, migration, test, regenerate_with}. hunks is the list of hunk ids in that file. regenerate_with is the ecosystem's regeneration command when the file is generated state (for example pnpm install --lockfile-only), and "" otherwise. |
| hunks | array | One entry per hunk, and the largest part of the payload. Fields below. |
| flags | array | Rule hits with stable ids: {id, severity, label, file, hunk_id, line, occurrences}. These are the entries the model must account for one by one. |
Each entry of prescan.hunks:
| Field | Type | Meaning |
|---|---|---|
| hunk_id | string | H1, H2, ... The model is instructed to refer to hunks by these ids and never to renumber them, so they are your join key between request and reply. |
| file | string | The path exactly as pasted. |
| line | number | 1-based line of the opening marker in the paste. |
| style | string | merge or diff3, per hunk. |
| ours_label | string | What followed the opening marker, usually HEAD. |
| theirs_label | string | What followed the closing marker, usually the incoming branch name. |
| classification | string | One of whitespace-only, import-block, one-side-empty, version-bump, overlapping-edit, lockfile, generated, migration-order, test-file, semantic, one-side-unchanged, superset, binary, empty, truncated. |
| our_lines | number | Line count of the ours side. |
| their_lines | number | Line count of the theirs side. |
| base_lines | number | Line count of the merge base. Meaningless unless style is diff3. |
| ours_empty | boolean | The ours side is empty - an addition against a deletion, the expensive case. |
| theirs_empty | boolean | The theirs side is empty. |
| base_equals_ours | boolean | diff3 only. When true, ours changed nothing here and theirs is the answer as a matter of fact, not judgement. |
| base_equals_theirs | boolean | The mirror image. |
| whitespace_only | boolean | The two sides differ only in whitespace, or not at all. |
| truncated | boolean | The paste stops inside this hunk. |
| mechanical_choice | string | What the deterministic resolver would take: ours, theirs, both, regenerate, or "" when it declines. |
| mechanical_certain | boolean | Whether that choice is provable from the markers alone. false means it is a hint, not a fact. |
| mechanical_reason | string | Why, in one clause. |
| ours | string | The ours side verbatim, capped at 1400 characters in the triage lane and 2600 in the other two. |
| theirs | string | The theirs side verbatim, same cap. |
| base | string or null | The merge base verbatim, or null when the hunk is two-way. |
The model must return exactly one coverage_check entry per flag id sent, and
none for ids that were not sent. That is what lets a free deterministic pass hold a paid
model pass accountable - anything unaccounted for is a defect you can detect programmatically:
sent = {f["id"] for f in payload["prescan"]["flags"]}
covered = {c["flag_id"] for c in result["coverage_check"]}
assert sent == covered, f"unreconciled: {sent - covered}"
Each coverage_check entry is {flag_id, status, finding_id, note}, where
status is confirmed (the flag is real and here is the finding),
set-aside (real but deliberately not acted on, with the reason in note) or
superseded (a different finding subsumes it). Any other value is coerced to
confirmed on the way in, so read note before believing a status.
You may send an empty prescan, or omit it. The lane still works; it simply has fewer
facts to ground itself in and nothing to reconcile, and the reply's hunk_id values will
be ids the model invented rather than ids you can join on.
The resolution_check contract (review lane)
For the review lane the page also compares your resolution against the
conflicted original mechanically and sends the result. It travels as counts and a
bounded sample, never the whole file a second time. Every key:
| Key | Type | Meaning |
|---|---|---|
| compared_against_file | string | Which pasted file the resolution was compared against, or "". A model told "three lines were dropped" without being told the comparison covered one of three files would draw the wrong conclusion, so the scope travels with the numbers. |
| scope_confident | boolean | Whether that file was identified confidently rather than guessed. |
| hunks_in_scope | number | How many hunks the comparison actually covered. |
| hunks_total | number | How many hunks exist in the paste. Lower hunks_in_scope than this is normal for a multi-file paste and is not a defect. |
| untouched_context_check_ran | boolean | Whether the "only the conflicted regions changed" comparison was possible at all. |
| markers_left | number | How many conflict-marker lines are still present in the resolution. Anything but zero fails the first named check. |
| dropped_from_ours | array | Up to 25 entries of {hunk_id, text}, each text capped at 160 characters: substantive lines present on the ours side and absent from the resolution. |
| dropped_from_theirs | array | The same for theirs. |
| dropped_from_ours_total | number | The real total, which may exceed the sampled 25. |
| dropped_from_theirs_total | number | The same for theirs. |
| untouched_context_lines_missing | array | Up to 15 lines from outside the conflicted regions that are missing from the resolution - how a resolution that quietly reformatted the file gets caught. |
| untouched_context_missing_total | number | The real total. |
| bracket_balance | object | {"{": 0, "[": 0, "(": 0} - net bracket delta between the conflicted original and the resolution. Non-zero is a strong signal the resolution will not parse. |
| json_parse_checked | boolean | Whether the file looked like JSON and was therefore parsed. |
| json_parse_ok | boolean | Whether that parse succeeded. |
| json_parse_error | string | The parser's message, or "". |
| base_only_lines_absent | number | How many absent lines came from the merge base only. These are not drops: a line the base had and neither side kept was deleted on purpose. |
Send resolution_check only on the review lane, and only if you can compute
it honestly. A fabricated one is worse than none: the model is instructed not to contradict a
deterministic fact without saying it believes the scanner mis-parsed, so wrong numbers here buy you
a confidently wrong review.
The output contract
The model replies with exactly one JSON object and nothing else: no prose before
it, no code fence around it. That object arrives as a string at
data.output.output, so parsing is two steps - the HTTP envelope, then the model's
object. Every key below is always present; every array is present even when empty. A reply that
cannot be parsed is re-asked once with a retry_note, which is one extra run.
{
"lane": "triage" | "resolve" | "review",
"lane_inferred": false,
"title": "short specific title naming the file or the change, not the lane",
"posture": "safe-to-resolve" | "needs-author" | "blocked",
"verdict": "one sentence a reviewer could paste into a pull request",
"summary": "3-6 sentences: what the conflict is about, and what was done about it",
"file_count": 3,
"hunk_count": 5,
"assumptions": ["..."],
"open_questions": ["..."],
"findings": [
{
"id": "MD-001",
"title": "short imperative title",
"severity": "critical" | "high" | "medium" | "low",
"hunk_id": "H3",
"file": "src/checkout/total.ts",
"line": 24,
"evidence": "verbatim lines from the input",
"why": "what goes wrong if this is merged as-is",
"fix": "what to do, in prose",
"fix_code": "optional replacement text, no conflict markers"
}
],
"coverage_check": [
{"flag_id": "MD-LOCKFILE", "status": "confirmed", "finding_id": "MD-002", "note": "..."}
],
"artifact": {"kind": "none" | "text" | "markdown", "filename": "", "content": ""},
"next_lane": {"lane": "", "reason": ""},
"body": { }
}
| Key | Type | Notes |
|---|---|---|
| lane | string | Must equal the task you sent. A reply naming no known lane is treated as unparseable and re-asked. |
| lane_inferred | boolean | true means your task did not arrive or was not recognised and the model chose for you. Assert it is false. |
| title | string | Names the file or the change, not the lane. Empty is replaced with "Untitled run". |
| posture | string | About the merge, not code quality. safe-to-resolve = every hunk can be settled from what was pasted. needs-author = at least one hunk needs a person who knows the intent behind one side. blocked = the paste is unusable (no markers, truncated mid-hunk, binary). Anything else is coerced to needs-author. |
| verdict | string | One sentence, pasteable into a pull request. |
| summary | string | Three to six sentences. |
| file_count | number | Must agree with prescan.counts.files unless a finding explains why not. |
| hunk_count | number | Must agree with prescan.counts.hunks on the same terms. |
| assumptions | string[] | What was taken as true to answer at all. Empty strings are dropped. |
| open_questions | string[] | Anything the model needed and was not given goes here rather than being invented. |
| findings | object[] | {id, title, severity, hunk_id, file, line, evidence, why, fix, fix_code}. Ids run sequentially from MD-001; the list is re-sorted most severe first on arrival, so do not rely on the wire order matching the id order. An unrecognised severity becomes medium. evidence is verbatim input, never a paraphrase. |
| coverage_check | object[] | One entry per prescan.flags id sent. Entries with no flag_id are discarded, which is why a missing id shows up as an unreconciled flag rather than a silent pass. |
| artifact | object | {kind, filename, content}. content is a whole file, never a diff and never a fragment, and contains no conflict markers. kind is forced to none if content is empty. |
| next_lane | object | {lane, reason}. lane is one of the three ids or "". |
| body | object | The lane's own payload. The three shapes are below, and they have nothing in common - a client that reads body must branch on lane first. |
body for task: "triage"
{
"hunks": [
{
"hunk_id": "H1",
"file": "src/checkout/total.ts",
"line": 3,
"classification": "whitespace-only" | "import-block" | "one-side-empty" |
"version-bump" | "overlapping-edit" | "lockfile" | "generated" |
"migration-order" | "test-file" | "semantic",
"recommendation": "take-ours" | "take-theirs" | "take-both" |
"hand-merge" | "regenerate" | "ask-author",
"confidence": "high" | "medium" | "low",
"risk": "critical" | "high" | "medium" | "low",
"reason": "why this recommendation, citing the hunk",
"intent_ours": "one clause: what the ours side was trying to do",
"intent_theirs": "one clause: what the theirs side was trying to do",
"needs_author": false
}
],
"order": ["H2", "H1", "H5", "H4", "H3"],
"auto_resolvable": 2,
"requires_judgement": ["H3"],
"regenerate_instead": [{"file": "pnpm-lock.yaml", "command": "pnpm install --lockfile-only"}],
"abort_advice": "",
"commands": ["git checkout --conflict=diff3 src/checkout/total.ts"]
}
- One entry in
body.hunksper hunk inprescan.hunks, never omitted, never added. The renderer joins onhunk_id, so an invented id shows up as an unmatched row rather than being quietly absorbed. orderis the order a person should work the hunks in - cheapest and most certain first, so the file shrinks before the hard decision is faced. It must be a permutation of the hunk ids. An id inorderthat is not inhunksis flagged in the UI as a defect in the answer.auto_resolvablecounts hunks whoserecommendationis anything other thanhand-mergeorask-author, atconfidence: "high".recommendation: "regenerate"is the only correct answer for a dependency lockfile. A hand-merged lockfile is never claimed to be valid.- When a hunk is diff3 and one side is byte-identical to the base, the recommendation is the
other side at
confidence: "high", andreasonsays that this is a fact rather than a judgement. intent_oursandintent_theirsare the point of this lane. The renderer prints both in a two-row table per hunk, and a recommendation with either missing reads as "not stated" - which is a coin toss dressed as triage.commandsmay includegit checkout --ours <path>,git checkout --theirs <path>,git checkout --conflict=diff3 <path>,git merge --abortandgit rerere. Nothing that force-pushes, resets hard or deletes a branch is ever emitted - and nothing is executed for you.artifact.kindisnonein this lane: triage decides, it does not write.
body for task: "resolve"
{
"strategy_note": "one paragraph: the through-line of how these hunks were merged",
"resolutions": [
{
"hunk_id": "H1",
"file": "src/checkout/total.ts",
"line": 3,
"resolved": "the exact replacement text for this hunk, no markers",
"kept_from_ours": ["verbatim line or short clause"],
"kept_from_theirs": ["verbatim line or short clause"],
"dropped_from_ours": ["verbatim line -- and the reason it did not survive"],
"dropped_from_theirs": [],
"rationale": "why this merged text is what both authors would accept",
"confidence": "high" | "medium" | "low",
"unresolved": false,
"unresolved_reason": ""
}
],
"retest": [{"what": "the behaviour to re-check", "why": "which side's change makes it necessary", "how": "a command or a manual step"}],
"followups": ["work this merge revealed but deliberately did not do"],
"commit_message": "a merge commit message that names what was reconciled"
}
- One entry per hunk. A hunk the model will not merge gets
unresolved: true, an emptyresolved, and a concreteunresolved_reasonnaming what it would need to know. That is an acceptable outcome; a plausible-looking guess is not. A hunk reported as merged with an emptyresolvedis rendered as a defect, not as a merge. artifactcarries the whole resolved file when the input was a single file:kind: "text",filenamecopied from the pasted path,contentthe complete file with every hunk replaced and no markers anywhere. When the paste held several files, the largest fully-resolved file is inartifactandstrategy_notesays which one.- Everything outside the hunks is preserved byte-for-byte: no reformatting, no import re-sorting, no renames, no typo fixes, no added comments. A merge that also refactors is unreviewable.
dropped_from_oursanddropped_from_theirsare mandatory whenever a side's line does not survive, and every entry ends with its reason after a literal--. The renderer splits on that separator: the part before it is shown as code, the part after as the justification. An empty array is an assertion that nothing was dropped, so it is only correct when that is true.retestis non-empty whenever any resolution hasconfidencebelowhigh.
body for task: "review"
{
"approval": "approve" | "approve-with-nits" | "request-changes" | "needs-author",
"checks": [
{
"name": "No conflict markers remain",
"status": "pass" | "fail" | "partial" | "unknown",
"evidence": "verbatim or a precise statement",
"requirement": "what a passing state looks like"
}
],
"dropped_changes": [
{
"side": "ours" | "theirs",
"hunk_id": "H3",
"content": "verbatim line that is gone",
"severity": "critical" | "high" | "medium" | "low",
"why_it_matters": "..."
}
],
"semantic_risks": [
{"statement": "...", "hunk_id": "H2", "likelihood": "high" | "medium" | "low", "check": "how to disprove it"}
],
"required_tests": [{"what": "...", "why": "...", "how": "..."}],
"review_comment": "markdown a reviewer could paste into the pull request verbatim"
}
checks covers nine named checks, by name and in this order:
No conflict markers remainEvery hunk was addressedNothing from ours was dropped silentlyNothing from theirs was dropped silentlySyntax and structure still plausibleOnly the conflicted regions changedImports and symbols still resolveBehaviour of both changes is preservedLockfiles and generated files were regenerated, not merged
- A check that cannot be evaluated from the paste is
unknownwithevidenceexplaining why - neverpassby default. The status string is used directly as a CSS class suffix, so a value outside the four allowed ones renders as an unstyled badge. dropped_changesis this lane's reason to exist. Hunk by hunk, for each side: which lines are inconflictedand absent fromresolution, and is that absence deliberate? A deletion the other side intended is a resolution, not a drop, and the answer must say which it is.approval: "approve"requires zerodropped_changesathighorcriticaland zero failing checks.needs-authoris for a resolution that is defensible only if an intent the model cannot see is true.review_commentis standalone markdown - a verdict line, the dropped-change table if any, and the re-test list. No JSON, no reference to the tool's internals. It is also whatartifactcarries, askind: "markdown".next_laneis set toresolvewhenapprovalisrequest-changes, so the flagged hunks can be merged again.
1. Get a token
A guest token is minted on demand and is enough for GET /me and
POST /estimate. Running a lane is metered, so it needs a personal
token: sign in at the token page and copy it from there - that page reads
the token this browser already holds for merge-desk, so you never open a developer
console. Every POST /guest mints a new guest identity, so reuse one token
across a session rather than minting per request.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="merge-desk"
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"$SLUG\"}" | tee guest.json
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
TOKEN=$(python3 -c "import json;print(json.load(open('guest.json'))['data']['token'])")
# For a metered run, replace this with your personal token:
# https://merge-desk.skillsafe.ai/tokens.html
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "merge-desk"
body = json.dumps({"slug": SLUG}).encode()
req = urllib.request.Request(BASE + "/guest", data=body, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as resp:
guest = json.loads(resp.read())["data"]
TOKEN = guest["token"] # reuse this for the whole session
print(guest["subject_type"]) # "guest"
# A metered run needs a personal token from
# https://merge-desk.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "merge-desk";
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG })
});
const guest = (await res.json()).data;
const token = guest.token; // reuse for the whole session
console.log(guest.subject_type); // "guest"
// A metered run needs a personal token from
// https://merge-desk.skillsafe.ai/tokens.html
guestBody := bytes.NewReader([]byte(`{"slug":"merge-desk"}`))
req, _ := http.NewRequest("POST", base+"/guest", guestBody)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
SubjectType string `json:"subject_type"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
fmt.Println(env.Data.SubjectType) // "guest"
// A metered run needs a personal token:
// https://merge-desk.skillsafe.ai/tokens.html
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"merge-desk\"}"))
.build();
HttpResponse<String> res = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
// {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
// A metered run needs a personal token:
// https://merge-desk.skillsafe.ai/tokens.html
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "merge-desk"
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => SLUG })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
guest = JSON.parse(res.body)["data"]
token = guest["token"] # reuse for the whole session
puts guest["subject_type"] # "guest"
# A metered run needs a personal token:
# https://merge-desk.skillsafe.ai/tokens.html
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "merge-desk";
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => SLUG]),
]);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
$token = $guest["token"]; // reuse for the whole session
echo $guest["subject_type"]; // "guest"
// A metered run needs a personal token:
// https://merge-desk.skillsafe.ai/tokens.html
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/guest");
req.Content = new StringContent("{\"slug\":\"merge-desk\"}", Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
var guest = JsonDocument.Parse(await res.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
var token = guest.GetProperty("token").GetString(); // reuse for the session
Console.WriteLine(guest.GetProperty("subject_type")); // "guest"
// A metered run needs a personal token:
// https://merge-desk.skillsafe.ai/tokens.html
2. A tiny client
A few lines of setup that every later step reuses: the base URL, the bearer token, and a JSON post
that raises on the error branch of the envelope. Keep the token in an environment
variable rather than a literal in a file you might commit - and note that the token is scoped to
this app, so it is useless for anything else and still worth not leaking.
# Every call in this document uses these three values.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="merge-desk"
TOKEN="YOUR_TOKEN" # from https://merge-desk.skillsafe.ai/tokens.html
post() { # post <path> <json>
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
get() { # get <path>
curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
}
import json
import os
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "merge-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, payload=None, method="POST", headers=None):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as resp:
envelope = json.loads(resp.read())
if not envelope.get("ok"):
raise RuntimeError(envelope["error"]["code"] + ": " + envelope["error"]["message"])
return envelope["data"]
def lane_result(job):
"""The model's object lives one layer deeper, as a string."""
return json.loads(job["output"]["output"])
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "merge-desk";
const TOKEN = "YOUR_TOKEN"; // from https://merge-desk.skillsafe.ai/tokens.html
async function call(path, payload, method = "POST", extraHeaders = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...extraHeaders
},
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const envelope = await res.json();
if (!envelope.ok) {
throw new Error(`${envelope.error.code}: ${envelope.error.message}`);
}
return envelope.data;
}
// The model's object lives one layer deeper, as a string.
const laneResult = (job) => JSON.parse(job.output.output);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "merge-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // or "YOUR_TOKEN"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, payload any, extra map[string]string) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class MergeDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "merge-desk";
static final String TOKEN =
System.getenv("SKILLSAFE_TOKEN") == null ? "YOUR_TOKEN" : System.getenv("SKILLSAFE_TOKEN");
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String method, String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
extra.forEach(b::header);
b = "GET".equals(method) ? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
}
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "merge-desk"
TOKEN = ENV["SKILLSAFE_TOKEN"] || "YOUR_TOKEN"
def call(path, payload = nil, method: :post, extra: {})
uri = URI(BASE + path)
req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(payload) unless payload.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
envelope = JSON.parse(res.body)
raise "#{envelope['error']['code']}: #{envelope['error']['message']}" unless envelope["ok"]
envelope["data"]
end
# The model's object lives one layer deeper, as a string.
def lane_result(job) = JSON.parse(job["output"]["output"])
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "merge-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN");
function call(string $path, ?array $payload = null, string $method = "POST",
array $extra = []): array {
$headers = array_merge([
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
], $extra);
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload === null ? null : json_encode($payload),
]);
$envelope = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($envelope["ok"])) {
throw new RuntimeException($envelope["error"]["code"] . ": " . $envelope["error"]["message"]);
}
return $envelope["data"];
}
function lane_result(array $job): array {
return json_decode($job["output"]["output"], true);
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public static class MergeDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "merge-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> CallAsync(string path, object payload = null,
HttpMethod method = null, IDictionary<string, string> extra = null)
{
var req = new HttpRequestMessage(method ?? HttpMethod.Post, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (extra != null)
foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
if (payload != null)
req.Content = new StringContent(JsonSerializer.Serialize(payload),
Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
var envelope = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!envelope.GetProperty("ok").GetBoolean())
{
var err = envelope.GetProperty("error");
throw new Exception($"{err.GetProperty("code")}: {err.GetProperty("message")}");
}
return envelope.GetProperty("data");
}
// The model's object lives one layer deeper, as a string.
public static JsonElement LaneResult(JsonElement job) =>
JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()).RootElement;
}
3. Check who you are and what you can spend
GET /me is free and tells you the subject type and the credit balance. Compare that
balance against the estimate in the next step before you run anything - a 402 after submitting is a
failure of the client, not of the user. The page disables its own run button with the shortfall
named rather than letting a run bounce.
get /me
# {"ok":true,"data":{"subject_type":"user","credits":48210, ...}}
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = call("GET", "/me", "", Map.of());
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","credits":48210, ...}}
me = call("/me", nil, method: :get)
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("/me", null, "GET");
echo $me["subject_type"] . " " . $me["credits"] . "\n";
var me = await CallAsync("/me", null, HttpMethod.Get);
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
4. Price the run before you make it
POST /estimate is free, creates no job and charges nothing. It takes the same input
object as a run and returns the model, the markup and - the number that matters -
hold_credits, the amount reserved while the run is in flight. It also returns
min_credits, below which the run is refused outright, and
sponsor_enabled, which says whether the app's free allowance would cover this run
without an account.
The hold differs per lane. The three lanes have different prompts and different
output caps: triage sends the two sides of each hunk trimmed to 1400 characters and
answers with one compact record per hunk, while resolve sends 2600 and has to write the
whole merged file back, and review carries a second paste - the resolution - on top of
the conflict. So an estimate for one lane is not an estimate for another: re-estimate whenever
task changes, which is exactly what the page does when you switch lane, discarding the
previous number rather than showing lane A's hold for lane B. Present it as reserved, never as the
price - the actual charge is usually far lower.
# payload.json holds the input object; see step 7 for the three lanes.
post /estimate "$(cat payload.json)"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4180,"min_credits":460,
# "sponsor_enabled":false}}
# Re-estimate after changing the lane - the hold is per lane.
for LANE in triage resolve review; do
python3 -c "import json,sys;p=json.load(open('payload.json'));p['task']=sys.argv[1];print(json.dumps(p))" \
"$LANE" > lane.json
echo -n "$LANE "
post /estimate "$(cat lane.json)"
done
payload = {
"task": "triage",
"conflicted": open("src/checkout/total.ts").read(),
"strategy_hint": "prefer-neither",
"branch_context": "ours is a hotfix that caps a single order; theirs is the EU VAT feature.",
}
est = call("/estimate", payload)
print(est["model"], est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")
# The hold is per lane, so price each lane you intend to run.
for lane in ("triage", "resolve", "review"):
per_lane = call("/estimate", dict(payload, task=lane))
print(lane, per_lane["hold_credits"])
import { readFileSync } from "node:fs";
const payload = {
task: "triage",
conflicted: readFileSync("src/checkout/total.ts", "utf8"),
strategy_hint: "prefer-neither",
branch_context: "ours is a hotfix that caps a single order; theirs is the EU VAT feature."
};
const est = await call("/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits, est.sponsor_enabled);
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}
// The hold is per lane, so price each lane you intend to run.
for (const task of ["triage", "resolve", "review"]) {
const perLane = await call("/estimate", { ...payload, task });
console.log(task, perLane.hold_credits);
}
conflicted, _ := os.ReadFile("src/checkout/total.ts")
payload := map[string]any{
"task": "triage",
"conflicted": string(conflicted),
"strategy_hint": "prefer-neither",
"branch_context": "ours caps a single order; theirs is the EU VAT feature.",
}
var est struct {
Model string `json:"model"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
// The hold is per lane, so price each lane you intend to run.
for _, lane := range []string{"triage", "resolve", "review"} {
payload["task"] = lane
data, err := call("POST", "/estimate", payload, nil)
if err != nil {
panic(err)
}
json.Unmarshal(data, &est)
fmt.Println(lane, est.Model, est.HoldCredits, est.MinCredits)
}
String conflicted = Files.readString(Path.of("src/checkout/total.ts"));
// JsonUtil.quote escapes the file into a JSON string literal.
String payload = """
{"task": "%s", "conflicted": %s, "strategy_hint": "prefer-neither"}
""";
// The hold is per lane, so price each lane you intend to run.
for (String lane : new String[] {"triage", "resolve", "review"}) {
String body = payload.formatted(lane, JsonUtil.quote(conflicted));
System.out.println(lane + " " + call("POST", "/estimate", body, Map.of()));
}
// {"ok":true,"data":{"model":"gpt-5.6-terra","hold_credits":4180,"min_credits":460, ...}}
payload = {
"task" => "triage",
"conflicted" => File.read("src/checkout/total.ts"),
"strategy_hint" => "prefer-neither",
"branch_context" => "ours caps a single order; theirs is the EU VAT feature."
}
est = call("/estimate", payload)
puts "#{est['model']} #{est['hold_credits']} #{est['min_credits']}"
abort "short by #{est['min_credits'] - me['credits']}" if me["credits"] < est["min_credits"]
# The hold is per lane, so price each lane you intend to run.
%w[triage resolve review].each do |lane|
puts "#{lane} #{call('/estimate', payload.merge('task' => lane))['hold_credits']}"
end
<?php
$payload = [
"task" => "triage",
"conflicted" => file_get_contents("src/checkout/total.ts"),
"strategy_hint" => "prefer-neither",
"branch_context" => "ours caps a single order; theirs is the EU VAT feature.",
];
$est = call("/estimate", $payload);
echo "{$est['model']} {$est['hold_credits']} {$est['min_credits']}\n";
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("short by " . ($est["min_credits"] - $me["credits"]));
}
// The hold is per lane, so price each lane you intend to run.
foreach (["triage", "resolve", "review"] as $lane) {
$perLane = call("/estimate", array_merge($payload, ["task" => $lane]));
echo "{$lane} {$perLane['hold_credits']}\n";
}
var conflicted = await File.ReadAllTextAsync("src/checkout/total.ts");
// The hold is per lane, so price each lane you intend to run.
foreach (var lane in new[] { "triage", "resolve", "review" })
{
var payload = new
{
task = lane,
conflicted,
strategy_hint = "prefer-neither",
branch_context = "ours caps a single order; theirs is the EU VAT feature."
};
var est = await CallAsync("/estimate", payload);
Console.WriteLine($"{lane} {est.GetProperty("hold_credits")} {est.GetProperty("min_credits")}");
}
5. Run a lane and poll for the result
POST /run returns a job_id immediately; poll GET /jobs/{id}
until status is terminal - succeeded, failed or
cancelled. Poll no faster than once a second or you will meet
rate_limited. Always send an Idempotency-Key derived from
the lane plus the input: two lanes over the same paste are two distinct runs and must not collide on
one key, and a retried request with the same key will never bill twice. Step 8 gives the exact
derivation the app uses.
On success the model's object is the string at data.output.output. Parse it, then
check three things before you use it: lane_inferred is false, every
prescan flag id appears in coverage_check, and hunk_count
matches what you sent.
KEY="merge-desk:triage:$(python3 -c "
import json,sys
p = json.load(open('payload.json'))
s = json.dumps([p['task'], p['conflicted'], p.get('resolution',''),
p['strategy_hint'], p.get('branch_context','')])
h = 5381
for ch in s: h = ((h * 33) + ord(ch)) & 0xFFFFFFFF
import string
d = string.digits + string.ascii_lowercase
o = ''
while h: o = d[h % 36] + o; h //= 36
print(o or '0')
"):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @payload.json | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['job_id'])")
until curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee job.json | grep -q '"status":"succeeded"'; do sleep 2; done
# The model's object is the string at data.output.output.
python3 -c "
import json
job = json.load(open('job.json'))['data']
r = json.loads(job['output']['output'])
print(r['posture'], '-', r['verdict'])
for h in r['body']['hunks']:
print(' ', h['hunk_id'], h['recommendation'], h['confidence'], h['file'])
"
import time
key = "merge-desk:%s:%s:a1" % (payload["task"], input_hash(payload)) # step 8
job = call("/run", payload, headers={"Idempotency-Key": key})
job_id = job["job_id"]
while True:
status = call("/jobs/" + job_id, method="GET")
if status["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if status["status"] != "succeeded":
raise RuntimeError("job " + status["status"])
result = lane_result(status)
assert not result["lane_inferred"], "the task field did not arrive"
sent = {f["id"] for f in payload.get("prescan", {}).get("flags", [])}
covered = {c["flag_id"] for c in result["coverage_check"]}
assert sent == covered, f"unreconciled prescan flags: {sent - covered}"
print(result["posture"], "-", result["verdict"])
for h in result["body"]["hunks"]:
print(f" {h['hunk_id']:4} {h['recommendation']:12} {h['confidence']:6} {h['file']}")
const key = `merge-desk:${payload.task}:${inputHash(payload)}:a1`; // step 8
const job = await call("/run", payload, "POST", { "Idempotency-Key": key });
let status;
do {
await new Promise(r => setTimeout(r, 2000));
status = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (!["succeeded", "failed", "cancelled"].includes(status.status));
if (status.status !== "succeeded") throw new Error(`job ${status.status}`);
const result = laneResult(status);
if (result.lane_inferred) throw new Error("the task field did not arrive");
const sent = new Set((payload.prescan?.flags ?? []).map(f => f.id));
const covered = new Set(result.coverage_check.map(c => c.flag_id));
for (const id of sent) {
if (!covered.has(id)) throw new Error(`unreconciled prescan flag: ${id}`);
}
console.log(result.posture, "-", result.verdict);
for (const h of result.body.hunks) {
console.log(` ${h.hunk_id} ${h.recommendation} ${h.confidence} ${h.file}`);
}
key := fmt.Sprintf("merge-desk:%s:%s:a1", payload["task"], inputHash(payload)) // step 8
data, err := call("POST", "/run", payload, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
}
json.Unmarshal(data, &job)
for {
time.Sleep(2 * time.Second)
statusData, err := call("GET", "/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
var st struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(statusData, &st)
if st.Status == "succeeded" {
var result struct {
Lane string `json:"lane"`
LaneInferred bool `json:"lane_inferred"`
Posture string `json:"posture"`
Verdict string `json:"verdict"`
}
json.Unmarshal([]byte(st.Output.Output), &result)
if result.LaneInferred {
panic("the task field did not arrive")
}
fmt.Println(result.Posture, "-", result.Verdict)
break
}
if st.Status == "failed" || st.Status == "cancelled" {
panic("job " + st.Status)
}
}
String key = "merge-desk:triage:" + inputHash(payload) + ":a1"; // step 8
String started = call("POST", "/run", payload, Map.of("Idempotency-Key", key));
String jobId = JsonUtil.path(started, "data", "job_id");
String status;
do {
Thread.sleep(2000);
status = call("GET", "/jobs/" + jobId, "", Map.of());
} while (!status.contains("\"status\":\"succeeded\"")
&& !status.contains("\"status\":\"failed\"")
&& !status.contains("\"status\":\"cancelled\""));
if (!status.contains("\"status\":\"succeeded\"")) {
throw new RuntimeException("job did not succeed: " + status);
}
// data.output.output is a JSON string; parse it a second time.
String inner = JsonUtil.path(status, "data", "output", "output");
System.out.println(JsonUtil.path(inner, "posture") + " - " + JsonUtil.path(inner, "verdict"));
key = "merge-desk:#{payload['task']}:#{input_hash(payload)}:a1" # step 8
job = call("/run", payload, extra: { "Idempotency-Key" => key })
status = nil
loop do
sleep 2
status = call("/jobs/#{job['job_id']}", nil, method: :get)
break if %w[succeeded failed cancelled].include?(status["status"])
end
raise "job #{status['status']}" unless status["status"] == "succeeded"
result = lane_result(status)
raise "the task field did not arrive" if result["lane_inferred"]
sent = (payload.dig("prescan", "flags") || []).map { |f| f["id"] }.to_set
covered = result["coverage_check"].map { |c| c["flag_id"] }.to_set
raise "unreconciled: #{(sent - covered).to_a.join(', ')}" unless sent == covered
puts "#{result['posture']} - #{result['verdict']}"
result["body"]["hunks"].each do |h|
puts " #{h['hunk_id']} #{h['recommendation']} #{h['confidence']} #{h['file']}"
end
<?php
$key = "merge-desk:{$payload['task']}:" . input_hash($payload) . ":a1"; // step 8
$job = call("/run", $payload, "POST", ["Idempotency-Key: " . $key]);
do {
sleep(2);
$status = call("/jobs/" . $job["job_id"], null, "GET");
} while (!in_array($status["status"], ["succeeded", "failed", "cancelled"], true));
if ($status["status"] !== "succeeded") {
throw new RuntimeException("job " . $status["status"]);
}
$result = lane_result($status);
if ($result["lane_inferred"]) {
throw new RuntimeException("the task field did not arrive");
}
$sent = array_column($payload["prescan"]["flags"] ?? [], "id");
$covered = array_column($result["coverage_check"], "flag_id");
$missing = array_diff($sent, $covered);
if ($missing) {
throw new RuntimeException("unreconciled: " . implode(", ", $missing));
}
echo "{$result['posture']} - {$result['verdict']}\n";
foreach ($result["body"]["hunks"] as $h) {
echo " {$h['hunk_id']} {$h['recommendation']} {$h['confidence']} {$h['file']}\n";
}
var key = $"merge-desk:{payload.task}:{InputHash(payload)}:a1"; // step 8
var job = await CallAsync("/run", payload, HttpMethod.Post,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = job.GetProperty("job_id").GetString();
JsonElement status;
string state;
do
{
await Task.Delay(2000);
status = await CallAsync($"/jobs/{jobId}", null, HttpMethod.Get);
state = status.GetProperty("status").GetString();
} while (state is not ("succeeded" or "failed" or "cancelled"));
if (state != "succeeded") throw new Exception($"job {state}");
var result = LaneResult(status);
if (result.GetProperty("lane_inferred").GetBoolean())
throw new Exception("the task field did not arrive");
Console.WriteLine($"{result.GetProperty("posture")} - {result.GetProperty("verdict")}");
foreach (var h in result.GetProperty("body").GetProperty("hunks").EnumerateArray())
{
Console.WriteLine($" {h.GetProperty("hunk_id")} {h.GetProperty("recommendation")}");
}
6. Stream it instead, for anything interactive
POST /run-stream is the same call over Server-Sent Events. Frames are named:
event: job once the job exists, event: delta with
{"text": "..."} as the answer generates, event: done with
{job_id, status, charged_credits, truncated, output}, and event: error with
{code, message, job_id} when the run fails. The same
Idempotency-Key rule applies - and on an idempotent replay the response comes back as
plain JSON rather than a stream, so check the Content-Type before assuming you have an
event stream.
Accumulate the deltas and parse once the stream closes: the envelope is only valid complete. The
page uses the partial text for progress only, advancing named stages when
"findings", then the lane's own key - "hunks",
"resolutions" or "checks" - then "coverage_check" appear in
the accumulating string. That is a display trick, not parsing; never act on a fragment.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d @payload.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"triage\",\"lane_inferred\":false,"}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":2864,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
chunks, done = [], None
with urllib.request.urlopen(req) as stream:
event = "message"
for raw in stream:
line = raw.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
frame = json.loads(line[5:].strip())
if event == "delta":
chunks.append(frame.get("text", ""))
print(".", end="", flush=True)
elif event == "done":
done = frame
elif event == "error":
raise RuntimeError(frame.get("code", "") + ": " + frame.get("message", ""))
result = json.loads("".join(chunks))
print("\n", result["lane"], result["posture"], result["verdict"])
print("charged", done and done.get("charged_credits"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
"Accept": "text/event-stream"
},
body: JSON.stringify(payload)
});
// An idempotent replay answers with plain JSON, not a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const replay = (await res.json()).data;
console.log(JSON.parse(replay.output.output).verdict);
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let name = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) name = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
const data = JSON.parse(dataStr);
if (name === "delta") text += data.text || "";
else if (name === "error") throw new Error(`${data.code}: ${data.message}`);
}
}
const result = JSON.parse(text);
console.log(result.lane, result.posture, result.verdict);
}
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var sb strings.Builder
name := "message"
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var frame struct {
Text string `json:"text"`
Code string `json:"code"`
Message string `json:"message"`
}
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &frame) != nil {
continue
}
if name == "delta" {
sb.WriteString(frame.Text)
} else if name == "error" {
panic(frame.Code + ": " + frame.Message)
}
}
}
fmt.Println(sb.String())
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
StringBuilder text = new StringBuilder();
final String[] name = {"message"};
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("event:")) {
name[0] = line.substring(6).trim();
} else if (line.startsWith("data:") && name[0].equals("delta")) {
text.append(JsonUtil.path(line.substring(5).trim(), "text"));
}
});
System.out.println(JsonUtil.path(text.toString(), "verdict"));
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.dump(payload)
text = +""
name = "message"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
name = line[6..].strip
elsif line.start_with?("data:")
frame = JSON.parse(line[5..].strip) rescue next
raise "#{frame['code']}: #{frame['message']}" if name == "error"
text << frame["text"].to_s if name == "delta"
end
end
end
end
end
result = JSON.parse(text)
puts "#{result['lane']} #{result['posture']} #{result['verdict']}"
<?php
$text = "";
$name = "message";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$name) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line);
if (str_starts_with($line, "event:")) {
$name = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$frame = json_decode(trim(substr($line, 5)), true);
if ($name === "delta" && isset($frame["text"])) $text .= $frame["text"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($text, true);
echo "{$result['lane']} {$result['posture']} {$result['verdict']}\n";
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new StringBuilder();
var name = "message";
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) { name = line[6..].Trim(); continue; }
if (!line.StartsWith("data:")) continue;
var frame = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (name == "delta" && frame.TryGetProperty("text", out var t)) text.Append(t.GetString());
else if (name == "error") throw new Exception(frame.GetProperty("message").GetString());
}
var result = JsonDocument.Parse(text.ToString()).RootElement;
Console.WriteLine($"{result.GetProperty("lane")} {result.GetProperty("verdict")}");
7. One worked example per lane
All three lanes take the same conflicted paste and differ only in task -
plus resolution and resolution_check for review. The envelope
is identical across all three; only body and artifact change. Fields shown
as ... follow the shapes documented above.
The paste every example below works from
# file: src/checkout/total.ts
import { Item } from "./types";
<<<<<<< HEAD
import { logger } from "../log";
||||||| merged common ancestors
=======
import { taxRateFor } from "../tax";
>>>>>>> feature/eu-vat
export interface TotalOptions {
currency: string;
<<<<<<< HEAD
region?: string;
||||||| merged common ancestors
region?: string;
=======
region: string;
vatInclusive: boolean;
>>>>>>> feature/eu-vat
}
export function total(items: Item[], opts: TotalOptions): number {
const subtotal = items.reduce((acc, i) => acc + i.price * i.qty, 0);
<<<<<<< HEAD
logger.debug("subtotal", { subtotal, currency: opts.currency });
if (subtotal > 100000) {
throw new Error("subtotal exceeds the single-order ceiling");
}
return Math.round(subtotal);
||||||| merged common ancestors
return Math.round(subtotal);
=======
const rate = taxRateFor(opts.region);
const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);
return Math.round(gross);
>>>>>>> feature/eu-vat
}
# file: src/checkout/total.test.ts
import { total } from "./total";
<<<<<<< HEAD
test("rejects an order over the ceiling", () => {
expect(() => total([{ price: 200000, qty: 1 }], { currency: "GBP" })).toThrow();
});
=======
test("adds VAT for a German order", () => {
expect(total([{ price: 1000, qty: 1 }], { currency: "EUR", region: "DE", vatInclusive: false }))
.toBe(1190);
});
>>>>>>> feature/eu-vat
# file: pnpm-lock.yaml
/intl-messageformat@10.5.0:
<<<<<<< HEAD
resolution: {integrity: sha512-aaaaOURSaaaa}
=======
resolution: {integrity: sha512-bbbbTHEIRSbbbb}
>>>>>>> feature/eu-vat
Three files, five hunks: H1 and H2 and H3 in
total.ts, H4 in the test file, H5 in the lockfile. Two of the
five are provable without a model - H2, where the diff3 base is byte-identical to ours,
and H5, which is a lockfile and must be regenerated. Run the whole thing through a lane
anyway: the two that are provable cost the model nothing to confirm, and the point of the run is
H3.
Running all three lanes over that one paste
# 1. triage the paste, 2. merge it, 3. review your own merged text.
# Each is a separate run with its own key. Nothing here touches your repository.
post /run "$(python3 -c "
import json
print(json.dumps({
'task': 'triage',
'conflicted': open('total.ts.conflicted').read(),
'strategy_hint': 'prefer-neither',
'branch_context': 'ours is a release hotfix that caps a single order; theirs is EU VAT.'
}))")"
post /run "$(python3 -c "
import json
print(json.dumps({
'task': 'resolve',
'conflicted': open('total.ts.conflicted').read(),
'strategy_hint': 'prefer-theirs',
'branch_context': 'The VAT work is the direction the codebase is going.'
}))")"
post /run "$(python3 -c "
import json
print(json.dumps({
'task': 'review',
'conflicted': open('total.ts.conflicted').read(),
'resolution': open('total.ts').read(),
'strategy_hint': 'prefer-neither'
}))")"
conflicted = open("total.ts.conflicted").read()
context = ("ours (HEAD) is a hotfix on release/4.2: a single-order ceiling and debug logging. "
"theirs (feature/eu-vat) makes region required and computes gross. "
"The VAT work is the direction the codebase is going.")
def run_lane(task, **extra):
payload = dict(task=task, conflicted=conflicted,
strategy_hint="prefer-neither", branch_context=context, **extra)
key = f"merge-desk:{task}:{input_hash(payload)}:a1"
job = call("/run", payload, headers={"Idempotency-Key": key})
return lane_result(call("/jobs/" + job["job_id"], method="GET")) # poll in real code
triage = run_lane("triage")
print(triage["body"]["order"], triage["body"]["auto_resolvable"])
merged = run_lane("resolve")
open("total.ts", "w").write(merged["artifact"]["content"]) # a whole file, no markers
review = run_lane("review", resolution=open("total.ts").read())
print(review["body"]["approval"], len(review["body"]["dropped_changes"]))
import { readFileSync, writeFileSync } from "node:fs";
const conflicted = readFileSync("total.ts.conflicted", "utf8");
const context =
"ours (HEAD) is a release hotfix: a single-order ceiling and debug logging. " +
"theirs (feature/eu-vat) makes region required and computes gross.";
async function runLane(task, extra = {}) {
const payload = { task, conflicted, strategy_hint: "prefer-neither",
branch_context: context, ...extra };
const key = `merge-desk:${task}:${inputHash(payload)}:a1`;
const job = await call("/run", payload, "POST", { "Idempotency-Key": key });
return laneResult(await call(`/jobs/${job.job_id}`, undefined, "GET")); // poll in real code
}
const triage = await runLane("triage");
console.log(triage.body.order, triage.body.auto_resolvable);
const merged = await runLane("resolve");
writeFileSync("total.ts", merged.artifact.content); // a whole file, no markers
const review = await runLane("review", { resolution: merged.artifact.content });
console.log(review.body.approval, review.body.dropped_changes.length);
conflicted, _ := os.ReadFile("total.ts.conflicted")
context := "ours is a release hotfix capping a single order; theirs is EU VAT."
runLane := func(task string, extra map[string]any) json.RawMessage {
payload := map[string]any{
"task": task, "conflicted": string(conflicted),
"strategy_hint": "prefer-neither", "branch_context": context,
}
for k, v := range extra {
payload[k] = v
}
key := fmt.Sprintf("merge-desk:%s:%s:a1", task, inputHash(payload))
data, err := call("POST", "/run", payload, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
return data // then poll /jobs/{id} as in step 5
}
runLane("triage", nil)
resolved := runLane("resolve", nil)
_ = resolved
runLane("review", map[string]any{"resolution": string(mustRead("total.ts"))})
String conflicted = Files.readString(Path.of("total.ts.conflicted"));
// One run per lane, one key per run. JsonUtil.quote escapes a string literal.
for (String lane : new String[] {"triage", "resolve", "review"}) {
StringBuilder body = new StringBuilder("{\"task\":\"" + lane + "\",")
.append("\"conflicted\":").append(JsonUtil.quote(conflicted)).append(",")
.append("\"strategy_hint\":\"prefer-neither\"");
if (lane.equals("review")) {
body.append(",\"resolution\":")
.append(JsonUtil.quote(Files.readString(Path.of("total.ts"))));
}
body.append("}");
String key = "merge-desk:" + lane + ":" + inputHash(body.toString()) + ":a1";
String started = call("POST", "/run", body.toString(), Map.of("Idempotency-Key", key));
System.out.println(lane + " -> " + JsonUtil.path(started, "data", "job_id"));
}
conflicted = File.read("total.ts.conflicted")
context = "ours is a release hotfix capping a single order; theirs is EU VAT."
def run_lane(task, conflicted, context, extra = {})
payload = { "task" => task, "conflicted" => conflicted,
"strategy_hint" => "prefer-neither", "branch_context" => context }.merge(extra)
key = "merge-desk:#{task}:#{input_hash(payload)}:a1"
job = call("/run", payload, extra: { "Idempotency-Key" => key })
lane_result(call("/jobs/#{job['job_id']}", nil, method: :get)) # poll in real code
end
triage = run_lane("triage", conflicted, context)
puts triage["body"]["order"].join(" ")
merged = run_lane("resolve", conflicted, context)
File.write("total.ts", merged["artifact"]["content"])
review = run_lane("review", conflicted, context, "resolution" => File.read("total.ts"))
puts review["body"]["approval"]
<?php
$conflicted = file_get_contents("total.ts.conflicted");
$context = "ours is a release hotfix capping a single order; theirs is EU VAT.";
function run_lane(string $task, string $conflicted, string $context, array $extra = []): array {
$payload = array_merge([
"task" => $task,
"conflicted" => $conflicted,
"strategy_hint" => "prefer-neither",
"branch_context" => $context,
], $extra);
$key = "merge-desk:{$task}:" . input_hash($payload) . ":a1";
$job = call("/run", $payload, "POST", ["Idempotency-Key: " . $key]);
return lane_result(call("/jobs/" . $job["job_id"], null, "GET")); // poll in real code
}
$triage = run_lane("triage", $conflicted, $context);
echo implode(" ", $triage["body"]["order"]) . "\n";
$merged = run_lane("resolve", $conflicted, $context);
file_put_contents("total.ts", $merged["artifact"]["content"]);
$review = run_lane("review", $conflicted, $context, ["resolution" => file_get_contents("total.ts")]);
echo $review["body"]["approval"] . "\n";
var conflicted = await File.ReadAllTextAsync("total.ts.conflicted");
var context = "ours is a release hotfix capping a single order; theirs is EU VAT.";
async Task<JsonElement> RunLane(string task, string resolution = null)
{
object payload = resolution is null
? new { task, conflicted, strategy_hint = "prefer-neither", branch_context = context }
: new { task, conflicted, resolution, strategy_hint = "prefer-neither",
branch_context = context };
var key = $"merge-desk:{task}:{InputHash(payload)}:a1";
var job = await CallAsync("/run", payload, HttpMethod.Post,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
// Poll /jobs/{id} as in step 5, then unwrap.
return LaneResult(await CallAsync($"/jobs/{job.GetProperty("job_id").GetString()}",
null, HttpMethod.Get));
}
var triage = await RunLane("triage");
var merged = await RunLane("resolve");
await File.WriteAllTextAsync("total.ts", merged.GetProperty("artifact")
.GetProperty("content").GetString());
var review = await RunLane("review", await File.ReadAllTextAsync("total.ts"));
Console.WriteLine(review.GetProperty("body").GetProperty("approval"));
task: "triage" — Triage every hunk
One record per hunk with both sides' intent recovered, a recommendation carrying its own confidence and risk, and the order to work them in. This is the only lane that emits no file.
Request
{
"task": "triage",
"conflicted": "<the paste above, as one JSON string>",
"strategy_hint": "prefer-neither",
"branch_context": "ours (HEAD) is a hotfix on release/4.2: it added a single-order ceiling and debug logging after an incident where a fat-fingered quantity produced a six-figure order. theirs (feature/eu-vat) is the EU VAT feature: it makes region required and computes gross. Both are shipping this week. The VAT work is the direction the codebase is going.",
"prescan": {
"style": "mixed",
"counts": {
"files": 3, "hunks": 5, "conflictLines": 42, "ourLines": 11, "theirLines": 11,
"certain": 2, "truncated": 0, "flags": 5, "inputLines": 62
},
"files": [
{"path": "src/checkout/total.ts", "hunks": ["H1", "H2", "H3"], "lockfile": false,
"generated": false, "migration": false, "test": false, "regenerate_with": ""},
{"path": "pnpm-lock.yaml", "hunks": ["H5"], "lockfile": true,
"generated": false, "migration": false, "test": false,
"regenerate_with": "pnpm install --lockfile-only"}
],
"hunks": [
{"hunk_id": "H2", "file": "src/checkout/total.ts", "line": 12, "style": "diff3",
"ours_label": "HEAD", "theirs_label": "feature/eu-vat",
"classification": "one-side-unchanged",
"our_lines": 1, "their_lines": 2, "base_lines": 1,
"ours_empty": false, "theirs_empty": false,
"base_equals_ours": true, "base_equals_theirs": false,
"whitespace_only": false, "truncated": false,
"mechanical_choice": "theirs", "mechanical_certain": true,
"mechanical_reason": "the base is identical to ours, so only theirs changed",
"ours": " region?: string;",
"theirs": " region: string;\n vatInclusive: boolean;",
"base": " region?: string;"},
{"hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24, "style": "diff3",
"ours_label": "HEAD", "theirs_label": "feature/eu-vat",
"classification": "overlapping-edit",
"our_lines": 5, "their_lines": 3, "base_lines": 1,
"ours_empty": false, "theirs_empty": false,
"base_equals_ours": false, "base_equals_theirs": false,
"whitespace_only": false, "truncated": false,
"mechanical_choice": "", "mechanical_certain": false,
"mechanical_reason": "both sides rewrote the same region; no provable choice",
"ours": " logger.debug(\"subtotal\", { subtotal, currency: opts.currency });\n if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }\n return Math.round(subtotal);",
"theirs": " const rate = taxRateFor(opts.region);\n const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);\n return Math.round(gross);",
"base": " return Math.round(subtotal);"}
],
"flags": [
{"id": "MD-OVERLAP-EDIT", "severity": "high",
"label": "both sides changed the same region in different ways",
"file": "src/checkout/total.ts", "hunk_id": "H3", "line": 24, "occurrences": 1},
{"id": "MD-LOCKFILE", "severity": "high",
"label": "the conflict is in a dependency lockfile",
"file": "pnpm-lock.yaml", "hunk_id": "H5", "line": 57, "occurrences": 1},
{"id": "MD-BASE-EQ-OURS", "severity": "low",
"label": "ours is identical to the merge base - only theirs changed",
"file": "src/checkout/total.ts", "hunk_id": "H2", "line": 12, "occurrences": 1}
]
}
}
Trimmed for the page: the real payload carries all three files, all five hunks and all five flags.
Send every hunk and every flag you have - the reply is required to have one entry per hunk in
body.hunks and one coverage_check entry per flag id, and a partial prescan
buys you a partial answer that still looks complete.
Response data.output.output, parsed
{
"lane": "triage",
"lane_inferred": false,
"title": "VAT rewrite against a release hotfix in total.ts",
"posture": "needs-author",
"verdict": "Four of five hunks settle mechanically; H3 drops either the order ceiling or VAT and needs the release owner.",
"summary": "The paste holds one merge of a release hotfix against the EU VAT feature. ...",
"file_count": 3,
"hunk_count": 5,
"assumptions": ["taxRateFor returns a fraction, not a percentage - nothing in the paste proves it."],
"open_questions": ["Is the single-order ceiling a permanent control or an incident stopgap?"],
"findings": [
{"id": "MD-001", "title": "Do not let the VAT rewrite delete the order ceiling",
"severity": "critical", "hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24,
"evidence": "throw new Error(\"subtotal exceeds the single-order ceiling\");",
"why": "Taking theirs whole removes the guard that was added after an incident, and the file still compiles.",
"fix": "Keep the ceiling check, then compute gross from the checked subtotal.",
"fix_code": ""},
{"id": "MD-002", "title": "Regenerate pnpm-lock.yaml instead of merging it",
"severity": "high", "hunk_id": "H5", "file": "pnpm-lock.yaml", "line": 57,
"evidence": "resolution: {integrity: sha512-aaaaOURSaaaa}",
"why": "A hand-picked integrity hash can be valid YAML and still not match the tarball.",
"fix": "Take either side, then run the regeneration command and commit the result.",
"fix_code": ""}
],
"coverage_check": [
{"flag_id": "MD-OVERLAP-EDIT", "status": "confirmed", "finding_id": "MD-001", "note": ""},
{"flag_id": "MD-LOCKFILE", "status": "confirmed", "finding_id": "MD-002", "note": ""},
{"flag_id": "MD-BASE-EQ-OURS", "status": "set-aside", "finding_id": "",
"note": "H2 is provable from the base; recorded as take-theirs, no finding needed."}
],
"artifact": {"kind": "none", "filename": "", "content": ""},
"next_lane": {"lane": "resolve", "reason": "Four hunks are ready to merge once H3 is decided."},
"body": {
"hunks": [
{"hunk_id": "H2", "file": "src/checkout/total.ts", "line": 12,
"classification": "one-side-unchanged", "recommendation": "take-theirs",
"confidence": "high", "risk": "low",
"reason": "The diff3 base is byte-identical to ours, so ours changed nothing here. That is a fact, not a judgement.",
"intent_ours": "leave the optional region field as it was",
"intent_theirs": "make region required and add the vatInclusive flag",
"needs_author": false},
{"hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24,
"classification": "overlapping-edit", "recommendation": "hand-merge",
"confidence": "medium", "risk": "critical",
"reason": "Both sides rewrote the return path. Neither side's text contains the other's change, so any single-side choice drops a shipped behaviour.",
"intent_ours": "cap a single order and log the subtotal after an incident",
"intent_theirs": "compute gross from the regional VAT rate",
"needs_author": true}
],
"order": ["H2", "H1", "H5", "H4", "H3"],
"auto_resolvable": 2,
"requires_judgement": ["H3"],
"regenerate_instead": [{"file": "pnpm-lock.yaml", "command": "pnpm install --lockfile-only"}],
"abort_advice": "",
"commands": [
"git checkout --conflict=diff3 src/checkout/total.ts",
"git checkout --theirs pnpm-lock.yaml"
]
}
}
task: "resolve" — Merge the hunks
The merged text, hunk by hunk, with every line from either side that did not survive named and given
a reason. artifact.content is the whole resolved file, ready to write over the original -
never a diff, never a fragment.
Request
{
"task": "resolve",
"conflicted": "<the same paste, as one JSON string>",
"strategy_hint": "prefer-theirs",
"branch_context": "The VAT work is the direction the codebase is going, but the order ceiling is an incident control and must survive.",
"prescan": {
"style": "mixed",
"counts": {"files": 3, "hunks": 5, "certain": 2, "truncated": 0, "flags": 5, "inputLines": 62},
"files": [
{"path": "src/checkout/total.ts", "hunks": ["H1", "H2", "H3"], "lockfile": false,
"generated": false, "migration": false, "test": false, "regenerate_with": ""}
],
"hunks": [
{"hunk_id": "H1", "file": "src/checkout/total.ts", "line": 3, "style": "diff3",
"ours_label": "HEAD", "theirs_label": "feature/eu-vat",
"classification": "import-block",
"our_lines": 1, "their_lines": 1, "base_lines": 0,
"ours_empty": false, "theirs_empty": false,
"base_equals_ours": false, "base_equals_theirs": false,
"whitespace_only": false, "truncated": false,
"mechanical_choice": "both", "mechanical_certain": false,
"mechanical_reason": "both sides are imports; take both and deduplicate",
"ours": "import { logger } from \"../log\";",
"theirs": "import { taxRateFor } from \"../tax\";",
"base": ""}
],
"flags": [
{"id": "MD-IMPORT-BLOCK", "severity": "low",
"label": "both sides are only import or use lines",
"file": "src/checkout/total.ts", "hunk_id": "H1", "line": 3, "occurrences": 1},
{"id": "MD-OVERLAP-EDIT", "severity": "high",
"label": "both sides changed the same region in different ways",
"file": "src/checkout/total.ts", "hunk_id": "H3", "line": 24, "occurrences": 1}
]
}
}
strategy_hint: "prefer-theirs" here is a tie-breaker for equivalent hunks, and the model
is instructed to ignore it - and say so in strategy_note - the moment it would drop a
substantive change. That is exactly what happens to H3 below: the hint says lean
theirs, and the answer keeps both.
Response data.output.output, parsed
{
"lane": "resolve",
"lane_inferred": false,
"title": "VAT gross calculation with the order ceiling kept, total.ts",
"posture": "safe-to-resolve",
"verdict": "Both branches survive: the ceiling still guards the subtotal, and gross is computed from the checked value.",
"summary": "H1 takes both imports. H2 takes theirs on the base-equality proof. H3 is hand-merged: ...",
"file_count": 3,
"hunk_count": 5,
"assumptions": ["taxRateFor(region) returns a fraction such as 0.19."],
"open_questions": ["Should the ceiling apply to the gross total rather than the subtotal?"],
"findings": [
{"id": "MD-001", "title": "The ceiling now guards the subtotal, not the gross",
"severity": "medium", "hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24,
"evidence": "if (subtotal > 100000) {",
"why": "With VAT added afterwards, an order can now exceed the old ceiling after tax.",
"fix": "Decide deliberately which figure the control applies to; this merge preserves the original behaviour.",
"fix_code": ""}
],
"coverage_check": [
{"flag_id": "MD-IMPORT-BLOCK", "status": "confirmed", "finding_id": "", "note": "H1 takes both imports; both are used in the merged file."},
{"flag_id": "MD-OVERLAP-EDIT", "status": "confirmed", "finding_id": "MD-001", "note": ""}
],
"artifact": {
"kind": "text",
"filename": "src/checkout/total.ts",
"content": "import { Item } from \"./types\";\nimport { logger } from \"../log\";\nimport { taxRateFor } from \"../tax\";\n\nexport interface TotalOptions {\n currency: string;\n region: string;\n vatInclusive: boolean;\n}\n\nexport function total(items: Item[], opts: TotalOptions): number {\n const subtotal = items.reduce((acc, i) => acc + i.price * i.qty, 0);\n logger.debug(\"subtotal\", { subtotal, currency: opts.currency });\n if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }\n const rate = taxRateFor(opts.region);\n const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);\n return Math.round(gross);\n}\n\nexport function formatTotal(cents: number, currency: string): string {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).format(cents / 100);\n}\n"
},
"next_lane": {"lane": "review", "reason": "H3 was hand-merged; have the merge reviewed before pushing."},
"body": {
"strategy_note": "Nothing shipped on either branch is discarded. The hint asked to lean theirs, and it is ignored at H3 because taking theirs whole would delete the order ceiling. total.ts is the file in artifact; the lockfile is regenerated rather than merged and the test file keeps both tests.",
"resolutions": [
{"hunk_id": "H1", "file": "src/checkout/total.ts", "line": 3,
"resolved": "import { logger } from \"../log\";\nimport { taxRateFor } from \"../tax\";",
"kept_from_ours": ["import { logger } from \"../log\";"],
"kept_from_theirs": ["import { taxRateFor } from \"../tax\";"],
"dropped_from_ours": [],
"dropped_from_theirs": [],
"rationale": "Two independent imports; both symbols are used in the merged file.",
"confidence": "high", "unresolved": false, "unresolved_reason": ""},
{"hunk_id": "H2", "file": "src/checkout/total.ts", "line": 12,
"resolved": " region: string;\n vatInclusive: boolean;",
"kept_from_ours": [],
"kept_from_theirs": ["region: string;", "vatInclusive: boolean;"],
"dropped_from_ours": ["region?: string; -- byte-identical to the merge base, so ours changed nothing here"],
"dropped_from_theirs": [],
"rationale": "The diff3 base equals ours, so theirs is the only change in this hunk.",
"confidence": "high", "unresolved": false, "unresolved_reason": ""},
{"hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24,
"resolved": " logger.debug(\"subtotal\", { subtotal, currency: opts.currency });\n if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }\n const rate = taxRateFor(opts.region);\n const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);\n return Math.round(gross);",
"kept_from_ours": [
"logger.debug(\"subtotal\", { subtotal, currency: opts.currency });",
"if (subtotal > 100000) {",
"throw new Error(\"subtotal exceeds the single-order ceiling\");"
],
"kept_from_theirs": [
"const rate = taxRateFor(opts.region);",
"const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);"
],
"dropped_from_ours": ["return Math.round(subtotal); -- superseded by the gross return, which both sides need"],
"dropped_from_theirs": [],
"rationale": "The guard runs on the subtotal before tax, exactly as ours intended, and the return is theirs. Neither branch loses its change.",
"confidence": "medium", "unresolved": false, "unresolved_reason": ""},
{"hunk_id": "H5", "file": "pnpm-lock.yaml", "line": 57,
"resolved": "",
"kept_from_ours": [], "kept_from_theirs": [],
"dropped_from_ours": [], "dropped_from_theirs": [],
"rationale": "",
"confidence": "high", "unresolved": true,
"unresolved_reason": "A lockfile is generated state. Take either side and run pnpm install --lockfile-only; a hand-merged integrity hash can be valid YAML and still wrong."}
],
"retest": [
{"what": "A GBP order over 100000 still throws",
"why": "The ceiling moved below the VAT calculation and must still fire",
"how": "pnpm test src/checkout/total.test.ts"},
{"what": "A German order returns gross, not net",
"why": "theirs added the rate multiplication that this merge preserved",
"how": "pnpm test src/checkout/total.test.ts -t VAT"}
],
"followups": [
"Decide whether the order ceiling should apply to the gross figure now that VAT exists.",
"The two tests in total.test.ts were both kept; neither was updated for the required region field."
],
"commit_message": "Merge feature/eu-vat into release/4.2\n\nKeeps the single-order ceiling and the debug log from the hotfix and computes gross from the checked subtotal using the regional VAT rate. region becomes required. pnpm-lock.yaml is regenerated rather than merged."
}
}
task: "review" — Review the resolution
The only lane that takes a second paste. Send resolution alongside
conflicted - and if you can compute it honestly, resolution_check, so the
model reconciles deterministic numbers instead of counting lines by eye. The example below reviews a
resolution that took theirs wholesale, which is the failure this lane exists to catch.
Request
{
"task": "review",
"conflicted": "<the same paste, as one JSON string>",
"resolution": "import { Item } from \"./types\";\nimport { taxRateFor } from \"../tax\";\n\nexport interface TotalOptions {\n currency: string;\n region: string;\n vatInclusive: boolean;\n}\n\nexport function total(items: Item[], opts: TotalOptions): number {\n const subtotal = items.reduce((acc, i) => acc + i.price * i.qty, 0);\n const rate = taxRateFor(opts.region);\n const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);\n return Math.round(gross);\n}\n\nexport function formatTotal(cents: number, currency: string): string {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).format(cents / 100);\n}\n",
"strategy_hint": "prefer-neither",
"branch_context": "ours is the release hotfix; theirs is EU VAT. Both shipped this week.",
"prescan": {
"style": "mixed",
"counts": {"files": 3, "hunks": 5, "certain": 2, "truncated": 0, "flags": 5, "inputLines": 62},
"files": [{"path": "src/checkout/total.ts", "hunks": ["H1", "H2", "H3"], "lockfile": false,
"generated": false, "migration": false, "test": false, "regenerate_with": ""}],
"hunks": [
{"hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24, "style": "diff3",
"ours_label": "HEAD", "theirs_label": "feature/eu-vat",
"classification": "overlapping-edit",
"our_lines": 5, "their_lines": 3, "base_lines": 1,
"ours_empty": false, "theirs_empty": false,
"base_equals_ours": false, "base_equals_theirs": false,
"whitespace_only": false, "truncated": false,
"mechanical_choice": "", "mechanical_certain": false,
"mechanical_reason": "both sides rewrote the same region; no provable choice",
"ours": " logger.debug(\"subtotal\", { subtotal, currency: opts.currency });\n if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }\n return Math.round(subtotal);",
"theirs": " const rate = taxRateFor(opts.region);\n const gross = opts.vatInclusive ? subtotal : subtotal * (1 + rate);\n return Math.round(gross);",
"base": " return Math.round(subtotal);"}
],
"flags": [
{"id": "MD-OVERLAP-EDIT", "severity": "high",
"label": "both sides changed the same region in different ways",
"file": "src/checkout/total.ts", "hunk_id": "H3", "line": 24, "occurrences": 1}
]
},
"resolution_check": {
"compared_against_file": "src/checkout/total.ts",
"scope_confident": true,
"hunks_in_scope": 3,
"hunks_total": 5,
"untouched_context_check_ran": true,
"markers_left": 0,
"dropped_from_ours": [
{"hunk_id": "H1", "text": "import { logger } from \"../log\";"},
{"hunk_id": "H3", "text": "logger.debug(\"subtotal\", { subtotal, currency: opts.currency });"},
{"hunk_id": "H3", "text": "if (subtotal > 100000) {"},
{"hunk_id": "H3", "text": "throw new Error(\"subtotal exceeds the single-order ceiling\");"}
],
"dropped_from_theirs": [],
"dropped_from_ours_total": 4,
"dropped_from_theirs_total": 0,
"untouched_context_lines_missing": [],
"untouched_context_missing_total": 0,
"bracket_balance": {"{": 0, "[": 0, "(": 0},
"json_parse_checked": false,
"json_parse_ok": true,
"json_parse_error": "",
"base_only_lines_absent": 2
}
}
Response data.output.output, parsed
{
"lane": "review",
"lane_inferred": false,
"title": "Resolution of total.ts drops the single-order ceiling",
"posture": "needs-author",
"verdict": "Do not push this: the VAT calculation is correct but the order ceiling and its log are gone from the merged file.",
"summary": "The resolution takes theirs wholesale at H1 and H3. ...",
"file_count": 3,
"hunk_count": 5,
"assumptions": ["The comparison covered src/checkout/total.ts only, as the scanner reported."],
"open_questions": ["Was the ceiling removed deliberately, or lost while resolving H3?"],
"findings": [
{"id": "MD-001", "title": "Restore the single-order ceiling before pushing",
"severity": "critical", "hunk_id": "H3", "file": "src/checkout/total.ts", "line": 24,
"evidence": "throw new Error(\"subtotal exceeds the single-order ceiling\");",
"why": "The guard was added after an incident and is absent from the resolution while the file still compiles - the classic silent drop.",
"fix": "Re-insert the debug log and the ceiling check before computing the rate.",
"fix_code": " logger.debug(\"subtotal\", { subtotal, currency: opts.currency });\n if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }"}
],
"coverage_check": [
{"flag_id": "MD-OVERLAP-EDIT", "status": "confirmed", "finding_id": "MD-001", "note": "The overlap was resolved by discarding one side."}
],
"artifact": {
"kind": "markdown",
"filename": "review-total-ts.md",
"content": "## Request changes\n\nThe VAT calculation is right. The order ceiling from release/4.2 is missing.\n\n| Side | Hunk | Gone | Severity |\n| --- | --- | --- | --- |\n| ours | H3 | the subtotal ceiling and its throw | critical |\n| ours | H1 | the logger import | high |\n\nRe-test: a GBP order over 100000 must still throw.\n"
},
"next_lane": {"lane": "resolve", "reason": "H1 and H3 need merging again, keeping both sides."},
"body": {
"approval": "request-changes",
"checks": [
{"name": "No conflict markers remain", "status": "pass",
"evidence": "The scanner reports markers_left: 0 and none is visible in the resolution.",
"requirement": "No line begins with a seven-character conflict marker."},
{"name": "Every hunk was addressed", "status": "partial",
"evidence": "H1, H2 and H3 are reflected in the resolution; H4 and H5 are in other files not included in this paste of the merged text.",
"requirement": "Each of the five hunks is either merged or explicitly deferred."},
{"name": "Nothing from ours was dropped silently", "status": "fail",
"evidence": "if (subtotal > 100000) { ... throw new Error(\"subtotal exceeds the single-order ceiling\");",
"requirement": "Every substantive ours line is present, or its removal is stated as intended."},
{"name": "Nothing from theirs was dropped silently", "status": "pass",
"evidence": "All three theirs lines at H3 and the taxRateFor import are present.",
"requirement": "The same test, for the incoming side."},
{"name": "Syntax and structure still plausible", "status": "pass",
"evidence": "Bracket balance is unchanged in all three kinds: {, [ and (.",
"requirement": "The merged file parses as TypeScript."},
{"name": "Only the conflicted regions changed", "status": "pass",
"evidence": "untouched_context_missing_total is 0; formatTotal is byte-identical.",
"requirement": "Nothing outside the hunks is reformatted, moved or deleted."},
{"name": "Imports and symbols still resolve", "status": "fail",
"evidence": "The logger import is gone, and so is its only use - consistent, but the debug log ours added is lost with it.",
"requirement": "Every symbol used is imported, and every import is used."},
{"name": "Behaviour of both changes is preserved", "status": "fail",
"evidence": "VAT is computed; the order ceiling is not enforced.",
"requirement": "Both branches' observable behaviour survives the merge."},
{"name": "Lockfiles and generated files were regenerated, not merged", "status": "unknown",
"evidence": "pnpm-lock.yaml is in the conflicted paste but not in the resolution, so nothing can be said about how it was settled.",
"requirement": "Every lockfile hunk is regenerated with the ecosystem's command."}
],
"dropped_changes": [
{"side": "ours", "hunk_id": "H3",
"content": "if (subtotal > 100000) {\n throw new Error(\"subtotal exceeds the single-order ceiling\");\n }",
"severity": "critical",
"why_it_matters": "This is the incident control from release/4.2. Its absence is invisible at compile time and only shows up as a six-figure order going through."},
{"side": "ours", "hunk_id": "H1",
"content": "import { logger } from \"../log\";",
"severity": "high",
"why_it_matters": "Dropped together with the debug line that used it, so the subtotal is no longer observable in logs."},
{"side": "ours", "hunk_id": "H3",
"content": "logger.debug(\"subtotal\", { subtotal, currency: opts.currency });",
"severity": "high",
"why_it_matters": "The diagnostic added during the incident. Cheap to keep, expensive to miss."}
],
"semantic_risks": [
{"statement": "region became required, and the existing GBP test calls total() without it.",
"hunk_id": "H2", "likelihood": "high",
"check": "pnpm tsc --noEmit and read the first error in total.test.ts"},
{"statement": "taxRateFor may return a percentage rather than a fraction, which would multiply the total by 20.",
"hunk_id": "H3", "likelihood": "low",
"check": "Open ../tax and read the return value for DE"}
],
"required_tests": [
{"what": "A GBP order over the ceiling throws",
"why": "It is the ours behaviour this resolution drops",
"how": "pnpm test src/checkout/total.test.ts -t ceiling"},
{"what": "A German net order returns 1190 for a 1000 subtotal",
"why": "It is the theirs behaviour this resolution keeps, and it must stay green after the ceiling is restored",
"how": "pnpm test src/checkout/total.test.ts -t VAT"}
],
"review_comment": "## Request changes\n\nThe VAT calculation is right and I would take it as-is. The problem is what left with it.\n\n**Dropped from ours (H3):** the single-order ceiling and its throw, plus the debug log. ...\n\n**Before pushing:** restore the guard above the rate calculation, then re-run both tests.\n"
}
}
Note what the deterministic resolution_check bought here: the model was told four ours
lines were absent and that two more absent lines came from the merge base only. Those two are
not drops - the base had them and neither side kept them - and the review does not report
them. Without base_only_lines_absent, a careful reviewer counting by eye would have
raised them as findings.
8. Idempotency: how the key is derived
The app builds its Idempotency-Key from the run, not from a random value, so that the
same request retried is the same run and a different request cannot collide with it:
merge-desk:<lane>:<hash>:a<attempt>
hash = base36( djb2( JSON.stringify([task, conflicted, resolution || "",
strategy_hint, branch_context || ""]) ) )
Exactly, field by field:
- The prefix is the app slug,
merge-desk, then the lane - so two lanes over the same paste are two distinct runs with two distinct keys. Leave the lane out and the second lane will be served the first lane's cached result, which is the one mistake in this document that costs you a wrong answer rather than an error. - The hashed value is a five-element array in this order:
task,conflicted,resolution(empty string when absent, so the two non-review lanes hash identically for the same paste),strategy_hint,branch_context(empty string when absent). Serialized as compact JSON - no spaces after the separators. - The hash is djb2: start at
5381, then for each characterh = (h * 33 + code) mod 2**32, unsigned. The app iterates UTF-16 code units, which is whatString.prototype.charCodeAtgives; for anything outside the basic multilingual plane a code-point iteration will differ. That only matters if you need to produce byte-identical keys to the web app. - The result is rendered in lowercase base 36, no padding.
:a<attempt>is the attempt counter, starting at1. When a reply does not parse, the app re-asks withretry_noteand:a2- a deliberately different key, because it is genuinely a second run and must not be served the broken first one from cache.prescan,resolution_checkandclip_noteare not in the hash. They are derived from the same two pastes, so including them would add nothing but instability across parser versions.
A worked pair. Over the step 7 paste, with strategy_hint: "prefer-neither" and that
branch_context, the triage run keys as
merge-desk:triage:hxra48:a1 and the resolve run over the same paste keys as
merge-desk:resolve:bfenr0:a1 - one character of input difference, an entirely different
key, which is the point. Every implementation below agrees on those two strings.
Those two values are computed over the real payload, newlines and all. The excerpt printed in step 7 is reflowed for the page, so retyping it will not reproduce them byte-for-byte - check your implementation against the JavaScript one below instead, which is the app's own.
# Shell has no 32-bit unsigned arithmetic worth trusting, so borrow python3.
# payload.json is the input object.
idem_key() { # idem_key <payload.json> <attempt>
python3 - "$1" "$2" <<'PY'
import json, string, sys
p = json.load(open(sys.argv[1]))
s = json.dumps([p["task"], p["conflicted"], p.get("resolution", ""),
p["strategy_hint"], p.get("branch_context", "")],
separators=(",", ":"), ensure_ascii=False)
h = 5381
for ch in s:
h = (h * 33 + ord(ch)) & 0xFFFFFFFF
digits = string.digits + string.ascii_lowercase
out = ""
while h:
out = digits[h % 36] + out
h //= 36
print("merge-desk:%s:%s:a%s" % (p["task"], out or "0", sys.argv[2]))
PY
}
KEY=$(idem_key payload.json 1)
echo "$KEY" # merge-desk:triage:hxra48:a1
post_with_key() { # post_with_key <path> <file> <key>
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $3" \
-d @"$2"
}
import json, string
DIGITS = string.digits + string.ascii_lowercase
def input_hash(payload):
"""djb2 over the same five fields the web app hashes, in base 36."""
s = json.dumps([payload["task"], payload["conflicted"], payload.get("resolution", ""),
payload["strategy_hint"], payload.get("branch_context", "")],
separators=(",", ":"), ensure_ascii=False)
h = 5381
for ch in s:
h = (h * 33 + ord(ch)) & 0xFFFFFFFF
out = ""
while h:
out = DIGITS[h % 36] + out
h //= 36
return out or "0"
def idem_key(payload, attempt=1):
return "merge-desk:%s:%s:a%d" % (payload["task"], input_hash(payload), attempt)
# Two lanes over one paste are two runs, and the keys say so.
triage = dict(payload, task="triage")
resolve = dict(payload, task="resolve")
assert idem_key(triage) != idem_key(resolve)
// This is the app's own implementation, verbatim in spirit.
function inputHash(input) {
const s = JSON.stringify([input.task, input.conflicted, input.resolution || "",
input.strategy_hint, input.branch_context || ""]);
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; // h * 33 + c, unsigned 32-bit
}
return h.toString(36);
}
function idemKey(input, attempt = 1) {
return `merge-desk:${input.task}:${inputHash(input)}:a${attempt}`;
}
// Two lanes over one paste are two runs, and the keys say so.
const triage = { ...payload, task: "triage" };
const resolve = { ...payload, task: "resolve" };
console.log(idemKey(triage) !== idemKey(resolve)); // true
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
)
// Go escapes <, > and & in JSON by default; the browser does not. Turn it off,
// or keys will differ from the web app's for any paste containing a marker.
func compactJSON(v any) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(v)
return string(bytes.TrimRight(buf.Bytes(), "\n"))
}
func inputHash(p map[string]any) string {
s := compactJSON([]any{
p["task"], p["conflicted"], strOr(p["resolution"], ""),
p["strategy_hint"], strOr(p["branch_context"], ""),
})
var h uint32 = 5381
for _, r := range utf16Units(s) {
h = h*33 + uint32(r)
}
return strconv.FormatUint(uint64(h), 36)
}
func idemKey(p map[string]any, attempt int) string {
return fmt.Sprintf("merge-desk:%s:%s:a%d", p["task"], inputHash(p), attempt)
}
func strOr(v any, def string) string {
if s, ok := v.(string); ok {
return s
}
return def
}
// Hash the same five fields, in order, as one compact JSON array.
static String inputHash(String task, String conflicted, String resolution,
String strategyHint, String branchContext) {
String s = "[" + JsonUtil.quote(task) + "," + JsonUtil.quote(conflicted) + ","
+ JsonUtil.quote(resolution == null ? "" : resolution) + ","
+ JsonUtil.quote(strategyHint) + ","
+ JsonUtil.quote(branchContext == null ? "" : branchContext) + "]";
long h = 5381L;
for (int i = 0; i < s.length(); i++) { // charAt is a UTF-16 unit, as in the browser
h = (h * 33 + s.charAt(i)) & 0xFFFFFFFFL;
}
return Long.toString(h, 36);
}
static String idemKey(String task, String hash, int attempt) {
return "merge-desk:" + task + ":" + hash + ":a" + attempt;
}
require "json"
def input_hash(payload)
s = JSON.generate([payload["task"], payload["conflicted"], payload["resolution"] || "",
payload["strategy_hint"], payload["branch_context"] || ""])
h = 5381
s.each_char { |ch| h = (h * 33 + ch.ord) & 0xFFFFFFFF }
h.to_s(36)
end
def idem_key(payload, attempt = 1)
"merge-desk:#{payload['task']}:#{input_hash(payload)}:a#{attempt}"
end
# Two lanes over one paste are two runs, and the keys say so.
raise "keys collided" if idem_key(payload.merge("task" => "triage")) ==
idem_key(payload.merge("task" => "resolve"))
<?php
function input_hash(array $payload): string {
// JSON_UNESCAPED_SLASHES matches the browser, which does not escape "/".
$s = json_encode([
$payload["task"],
$payload["conflicted"],
$payload["resolution"] ?? "",
$payload["strategy_hint"],
$payload["branch_context"] ?? "",
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$h = 5381;
foreach (preg_split("//u", $s, -1, PREG_SPLIT_NO_EMPTY) as $ch) {
$h = ($h * 33 + mb_ord($ch, "UTF-8")) & 0xFFFFFFFF;
}
return base_convert((string) $h, 10, 36);
}
function idem_key(array $payload, int $attempt = 1): string {
return "merge-desk:{$payload['task']}:" . input_hash($payload) . ":a{$attempt}";
}
// Two lanes over one paste are two runs, and the keys say so.
$a = idem_key(array_merge($payload, ["task" => "triage"]));
$b = idem_key(array_merge($payload, ["task" => "resolve"]));
assert($a !== $b);
using System.Text.Encodings.Web;
using System.Text.Json;
static string InputHash(string task, string conflicted, string resolution,
string strategyHint, string branchContext)
{
// UnsafeRelaxedJsonEscaping stops <, > and & being escaped, which the browser
// does not escape either - conflict markers would otherwise change the hash.
var opts = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var s = JsonSerializer.Serialize(new[]
{
task, conflicted, resolution ?? "", strategyHint, branchContext ?? ""
}, opts);
uint h = 5381;
foreach (char c in s) h = unchecked(h * 33 + c); // char is a UTF-16 unit
const string digits = "0123456789abcdefghijklmnopqrstuvwxyz";
if (h == 0) return "0";
var outp = "";
while (h > 0) { outp = digits[(int)(h % 36)] + outp; h /= 36; }
return outp;
}
static string IdemKey(string task, string hash, int attempt = 1) =>
$"merge-desk:{task}:{hash}:a{attempt}";
Two consequences worth stating plainly, because both have bitten someone:
- Two lanes over the same paste are two distinct runs. Triaging a conflict and
then resolving it is two charges, and it should be: they are different questions with different
answers. Include
taskin the key and they never collide. - Retrying a 5xx with the same key is free. That is what the key is for. Retrying with a new key is a second run and a second charge, so never regenerate the key inside a retry loop - derive it once, outside.
What this API does not do
It never runs git: no clone, no fetch, no checkout, no
merge --abort, no staging, no commit, no push. It never touches a
repository - it has no credentials for one and never asks for any. It never
executes code: it does not run your tests, import your module, type-check the file or
evaluate the merged result. When the answer says a resolution "keeps X", that is a claim about the
text; when it says a test should be re-run, that is a command for you to run and read.
Every command in body.commands, retest[].how and
required_tests[].how is a string for a human to read first. Nothing on this API
executes anything.
The second limit is the one that actually changes how you write a client.
The deterministic conflict parser and the mechanical resolver run only in the
browser. They are what produce prescan - the hunk ids, the classifications, the
base-equality proofs, the lockfile detection, the rule flags - and what produce
resolution_check for the review lane. They are page code, not an endpoint: there is no
POST /prescan, and the API will not compute either object for you.
So an API caller has two honest options:
- Build
prescanyourself from your own parse of the markers, following the shapes above. Then the reconciliation contract works for you exactly as it works for the page: onecoverage_checkentry per flag id you sent, and an assertion you can run in three lines. This is the better path if you are running Merge Desk over many conflicts. - Omit it. The lane still answers. You lose the ability to check the reply against
anything, the hunk ids become the model's own invention rather than a join key, and
file_countandhunk_counthave nothing to agree with. That is a real loss, not a formality - the free deterministic pass is what makes the paid pass auditable.
What the API does not lose by omitting prescan: the model still reads the markers itself.
It parses two-way and diff3 material, it will tell you when a paste has no markers at all
(posture: "blocked"), when it stops mid-hunk, and when one enormous hunk means the merge
should be redone rather than finished. It simply has no second opinion to be held against.
Notes that will save you a support round trip
- Send the file as
gitleft it. The commonest wasted run is a paste that has already been half-edited: the markers are gone, so there is nothing to triage and the answer says so.conflictedwants the marker-bearing version. - Two lanes over one paste are two runs. Include
taskin the idempotency key or the second lane will be served the first lane's cached result. branch_contextis the cheapest quality win here. Without it the model can classify a hunk but cannot recover why either side did what it did - andintent_ours/intent_theirsare most of the value of the triage lane.- Clip the conflict by removing context, not by cutting the tail. The page removes unconflicted context from between the hunks above 56000 characters and marks every omission in band; it never silently drops a hunk without saying how many went. A head-only slice throws away whole hunks and produces an answer that is confidently about the wrong file.
artifact.contentis a whole file, never a diff and never a fragment, and contains no conflict markers. Write it straight over the original - after reading it.- Check
lane_inferred. If it istrue, yourtaskfield did not arrive or was not recognised, and the model chose the lane for you. - Reconcile
coverage_checkyourself. A flag you sent that comes back unaccounted for is still your problem regardless of how confident the rest of the answer sounds. The page prints the shortfall in red; a client should assert on it. - A lockfile hunk is never merged. Expect
recommendation: "regenerate"in triage andunresolved: truein resolve, with the ecosystem's command. That is the correct answer, not a refusal. postureis about the merge, not the code.safe-to-resolvedoes not mean the code is good; it means every hunk can be settled from what you pasted.- Secrets. If the pasted material contains something that looks like a credential, the answer names its location and tells you to rotate it, and does not repeat the value. Assume the paste itself was still transmitted - rotate anyway.