A failed kanban completion still binds the pull request

John Burns

The card was still blocked. hermes kanban show --json listed completion_contract as https://github.com/acme/repo/pull/7. Create, a few seconds earlier, had stored acme/repo. Complete had exited 1. Stderr was one sentence: cannot complete t_ab9031f6 (unknown id or terminal state). The id was the one create had printed. The status was not terminal. No gh process had been started for that card.

The kanban page already says which login must read a pull request. It does not say that a failed read rewrites the stored contract, or that complete reports the refusal as a missing id. Those two results are what this binary recorded.

The board the commands wrote

The binary was Hermes Agent v0.22.5+471.g5894e75, local commit 5894e750c3db, on Linux x86_64, kernel 7.0.14-6-pve, on 12 November 2026. hermes --version also printed Python 3.14.9 for the agent runtime. The board file was a new SQLite database selected with HERMES_KANBAN_DB. HERMES_HOME pointed at a new directory. The operator board file’s timestamp was the same after the run as before it. No login was run. No prompt was sent. The gateway that was already running was not the process under test.

The stand-in for gh was a script placed earlier on PATH. It recorded which of two probe values it received for GH_TOKEN and GITHUB_TOKEN, whether GH_CONFIG_DIR was set, and the HERMES_HOME value in the child. It then exited 4. That is the authentication-required code this build treats as no login. The script did not contact GitHub. The shell exported one probe value under each token name. A profile named probeboard had the same names set to different probe values in its .env. A second profile, probbare, had a SOUL.md and no token file. A third assignee, nosuchprofile, had no directory.

The kanban page, read the same day at https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban, says to declare pull-request work with --completion-contract OWNER/REPO, then pass metadata.published_pr at completion. It says the first matching URL binds the card permanently, and that retries cannot substitute a green sibling pull request. It says the acceptance read runs as the assignee profile’s gh login, using GH_TOKEN or GH_CONFIG_DIR from that profile’s .env, and that a GH_TOKEN exported in the shell or a systemd unit is scrubbed and never re-added. It says a profile with no login of its own, or one that no longer exists, is refused not logged in rather than falling through to ~/.config/gh. Unassigned cards still use the ambient login. A refusal is classification=auth, and last_failure_error surfaces the next step.

hermes kanban complete --help on this binary does not mention the contract, the metadata key, or that refusal. The help text says --metadata is a JSON dict stored on the closing run, and the example keys are changed_files and tests_run. The failure sentence it will print is not in the help text either.

Create does not check the assignee. Every card was created with --workspace scratch and --initial-status blocked, so no worker was spawned and no worktree was added to the checkout the shell was sitting in. Create exited 0 for the missing name as well as for the two profiles. The JSON it printed had status blocked, completion_contract acme/repo or local-only, and last_failure_error null.

Which login the child actually had

Five completions followed, each with --result set to a short evidence string so the empty-completion gate would not be the thing under test. The four pull-request cards passed --metadata with published_pr set to https://github.com/acme/repo/pull/7.

The unassigned card started the stand-in. The child saw the shell probe values. GH_CONFIG_DIR was unset. HERMES_HOME in that child was the disposable home, not a profile directory. HOME was the launching user’s home. The page’s ambient-login sentence matches that child.

The probeboard card started the stand-in. The child saw the profile probe values, not the shell values. HERMES_HOME was that profile’s directory. GH_CONFIG_DIR was unset, because the profile file had already supplied the token names. HOME was still the launching user’s home. The scrub is of the token names, not of HOME. A check that only prints HOME will not see the switch.

The probbare card started the stand-in. Both token names were unset. GH_CONFIG_DIR was that profile’s directory with /gh appended. The directory had not been created by this run. The shell values were not present. HOME was again the launching user’s home. The page says this case must not fall through to ~/.config/gh. The mechanism this binary used was the config-directory pin, not a different HOME. The stand-in still ran. “No login” is not the same as “do not start gh”.

The nosuchprofile card did not start the stand-in. There was no call between the marker written before complete and the marker written after it. The page groups a missing profile with a profile that has no login, and it says both are refused not logged in. On this binary those are different processes. The bare profile starts gh with the pin. The missing name does not start gh at all.

The local-only card passed no metadata. The stand-in did not run. Complete exited 0 and printed Completed, followed by the task id. Show reported status done and left the contract as local-only. That is the path that does not talk to GitHub.

The sentence complete prints

The four pull-request cards all exited 1. Stderr was the same shape each time, with that card’s id in the sentence:

cannot complete t_ab9031f6 (unknown id or terminal state)

Show still listed each of those cards as blocked. The id was in the create output. The status was the status create had set. A wrapper that branches on that sentence will look up the wrong failure. The acceptance result is on the task, and in a pr_acceptance event, after you ask show for JSON.

For the three cards whose stand-in exited 4, last_failure_error began PR acceptance auth:. The detail was gh has no login for graphql, naming either the ambient login or the assignee profile. Classification was auth. The page’s words not logged in were not in that string. The profile name was followed by a doubled apostrophe, as in probeboard''s gh login. That spelling is what the event stored. It is not a transcription error.

For the missing profile, the detail was assignee profile 'nosuchprofile' cannot be resolved. Classification was still auth. The event was written. gh was not started. The recovery sentence appended to last_failure_error still says to fix that profile’s GitHub credentials and retry completion. There is no profile to fix. The next step the sentence names is not the step this failure has.

On every refused pull-request card, completion_contract was no longer acme/repo. It was https://github.com/acme/repo/pull/7. The bind does not wait for a successful read. A profile that cannot be resolved is enough to store the URL and leave the card blocked. The page says the first matching URL binds the card permanently. It does not say the write happens before gh is asked, or before the assignee directory is confirmed.

A later URL does not move the contract

The missing-profile card was completed again with https://github.com/acme/repo/pull/9, then again with pull 7. Both attempts exited 1 with the same unknown-id sentence. Neither started gh. After both, the task’s completion_contract was still pull 7.

The events did not stay aligned with that field. The pull 9 attempt appended a pr_acceptance event whose pr_url was pull 9. The detail was still the resolve failure, not a sentence saying the URL did not match the stored contract. The pull 7 retry appended a third event whose pr_url was pull 7 and whose detail was the same resolve failure. A script that reads the latest event’s pr_url and ignores task.completion_contract will report the attempt, not the bind.

The page says retries cannot substitute a green sibling pull request. On this binary that is true of the task field. It is not what the latest event’s pr_url shows, and the mismatch sentence is not what the event stores when the profile cannot be resolved. The resolve error is raised before the URL comparison. This run did not repeat the sibling attempt against a profile that exists, so it does not say whether a resolvable profile would store the mismatch sentence instead. It says the missing-profile path never reaches that comparison, and the contract field still does not move.

Verify the card after complete returns

Do not treat the complete exit, or the unknown-id sentence, as the acceptance result. After any completion that passed published_pr, run show with --json and read three fields.

status has to be done before the card is closed. A refused read left blocked on the cards this run created that way. The gate also accepts ready, running, and review. This run did not complete a card in those statuses, so it does not say which of them a refusal preserves.

completion_contract is the bind. Once it is a pull-request URL, a later metadata value does not replace it, even when that later attempt fails before gh starts. If the first URL was wrong, the card stays on that URL. This run did not find a complete flag that clears the contract, and it did not edit the row by hand. Treat the first published_pr as permanent even when complete exits 1.

last_failure_error and the latest pr_acceptance payload carry the classification. auth means the login that was asked to read the repository could not. For an assigned card that login is the profile, not the shell that ran complete. gh auth status in that shell is the wrong check once an assignee is set. Read the profile .env for GH_TOKEN, GITHUB_TOKEN, or GH_CONFIG_DIR before you retry. A profile with no token still starts gh, with those names removed and GH_CONFIG_DIR pointed at a gh directory under the profile even when that directory is absent. A name with no profile directory does not start gh, and the event says the profile cannot be resolved. Create will still store that name. Confirm the assignee directory before you pass a URL, because the URL is stored on the failure.

The local-only card is the control. No metadata, no gh, exit 0, status done, contract unchanged. If the card does not need a repository check, set --completion-contract local-only at create. This run set that flag explicitly. It did not measure a card created with the flag omitted. The page says undeclared cards keep the local-only default. The help text on create names the flag. The measurement is the flag as passed, not the default path.

The acceptance read was not a GitHub result. The stand-in exited 4 before any network call, so this note does not say which check-run classification a live repository would store, and it does not say that a real gh on this host would have exited 4. It says which environment the read used, and what the board recorded when that read was refused. Re-run the show after you point the profile at a login that can see the repository. The unknown-id sentence will not tell you that the login changed.