08 · advanced

PR-flow panel verbs

Four repetitive PR-lifecycle acts, running as slash commands in the SAME agent panel you already dispatch missions from: /pr-list, /pr-info, /pr-approve, /pr-merge. They are operator-authored procedural.shell mission configs, not a built-in feature: darkmux ships the machinery (the allowlist, the permission dialog, the audit trail), you author the verbs against your own repos and your own gh.

Why this exists

The rest of a coding workflow is already automated through frontier sessions: a bot account authors PRs, a darkmux review posts CI-shaped findings, and the only thing left is the daily context switch to actually decide. darkmux acp's agent panel is where the review lands, so the deciding step belongs there too, not in a browser tab.

The architecture keeps one line firm: GitHub never enters darkmux core. The binary has no GitHub credential, sends no GitHub API calls, and knows nothing about pull requests as a concept. Every verb below is a plain shell command that invokes the OPERATOR's own gh (the same posture as the existing lms shell-out), assembled into the standard procedural.shell step kind (see the missions guide for that graph model). What darkmux contributes is generic, reusable structure around that shell-out:

The three pieces

1. The allowlist (cmd.enabled / cmd.allowed)

darkmux ships an enabled-gated config block, same shape as redis/audit/every other feature block: darkmux init writes it visible and off.

{
  "gh": {
    "enabled": false,
    "allowed": []
  }
}

A config opts into the gate by declaring a cmd name at its top level (schema 2.3): "cmd": "pr-merge". Before darkmux runs a SINGLE step of that config's graph, on EITHER entry point (a panel slash command, or a bare darkmux mission launch pr-merge from the terminal), it checks cmd.enabled == true and that "pr-merge" is listed in cmd.allowed. Fails closed on both: the block off refuses everything regardless of the list; a verb absent from the list refuses even with the block on.

darkmux config set cmd.enabled true
darkmux config set cmd.allowed pr-list,pr-info,pr-approve,pr-merge

The second command REPLACES the whole list (there is no incremental add today), so name every verb you want live. darkmux doctor shows the resolved state:

✓ gh verb allowlist      enabled (config.json) — allowed: pr-list, pr-info, pr-approve, pr-merge

2. The permission dialog

The operator sign-off gate is already generic machinery in darkmux (any step can declare "gate": "operator"; see the missions guide). This feature doesn't add a new gate; it just puts REAL facts into the dialog body. A gated step's dialog shows the composed output of whatever step it depends on, one line per fact. So the pattern for a state-changing verb is two tasks: a read-only gather task that shells out to gh and prints the facts, then a gate: "operator" task that acts:

{
  "id": "gather",
  "steps": [{ "id": "gather-step", "kind": "procedural.shell",
    "config": { "command": "gh pr view ... -q '...ci/review/adjudication text...'" } }]
},
{
  "id": "do-merge",
  "depends_on": ["gather"],
  "steps": [{ "id": "do-merge-step", "kind": "procedural.shell", "gate": "operator",
    "config": { "command": "gh pr merge \"$PR\" --squash" } }]
}

In Zed this renders as a native session/request_permission dialog whose body is gather's own output, verbatim. The example pr-approve/pr-merge verbs below gather exactly the facts the operator needs to decide without leaving the panel:

3. The audit record

Every EXECUTED command command (a config that reached the allowlist gate and actually ran, whatever its outcome) emits one flow record: category: audit, action: "gh.verb.executed", with the verb name, a best-effort PR number (the first token typed after the slash command), the worktree it ran from, whether the operator confirmed a gate, and success/failure, all in payload. This is the same audit-trail convention darkmux flow note --source adjudication already uses, not a parallel channel; it reads through the ordinary /flow viewer.

The cwd invariant

Every verb below resolves its target SOLELY from the session's working directory, never from a global "current PR" or any state carried between sessions. In practice this falls out of gh's own behavior for free: gh pr list and gh pr view (with no explicit --repo) both infer the repository from the cwd's git remote. A procedural.shell step's cwd is filled in from the session's own cwd when the step doesn't declare one itself, on both entry points (the panel route fills it explicitly; a direct CLI launch simply inherits the invoking terminal's cwd). Two concurrent darkmux sessions in two different worktrees never see each other's PRs by accident.

The four example verbs

These are examples, not built-ins: copy what you want into ~/.darkmux/mission-configs/ (one file per verb, named after its id) and tune the gh flags to your own repo's conventions. Every command below was run live against a real PR on this repo before being written down here.

pr-list.json: no arguments, read-only

{
  "id": "pr-list",
  "name": "PR List",
  "schema_version": "2.3",
  "cmd": "pr-list",
  "panel": {
    "description": "List open pull requests in this repo",
    "hint": "(no arguments)",
    "accepts_args": false
  },
  "phases": [
    {
      "id": "list",
      "tasks": [{
        "id": "list-open-prs",
        "steps": [{
          "id": "list-open-prs-step",
          "kind": "procedural.shell",
          "config": {
            "command": "gh pr list --json number,title,author,isDraft,headRefName --limit 30 | jq -r 'if length == 0 then \"no open pull requests\" else .[] | \"#\\(.number)  \\(.title)  [\\(.headRefName)]\\(if .isDraft then \" (draft)\" else \"\" end)  @\\(.author.login)\" end'"
          }
        }]
      }]
    }
  ]
}

No gate, no reads: listing is not a consequential act, so it only needs the allowlist. accepts_args: false says the command takes no text after its name, and radio enforces it: trailing words the routing model carried over are dropped rather than handed to a command with nowhere to put them. Leave the field out (the default) for any verb that does read an argument. Live output looks like:

#1777  feat(acp): cancellation, session pruning, and the chunk-noise filter  [acp/1684-remainder]  @kstrat2001
#1757  docs(guide): radio — full v2.6 surface  [docs/radio-guide] (draft)  @kstrat2001

pr-info.json: the shared gather logic, standalone

Takes an optional PR number (/pr-info 1776); with none, resolves the CURRENT branch's PR via a bare gh pr view. This is the exact gather logic pr-approve/pr-merge reuse below, so reading it here first makes those two easier to follow.

{
  "id": "pr-info",
  "name": "PR Info",
  "schema_version": "2.3",
  "cmd": "pr-info",
  "inputs": [
    { "name": "args", "required": false,
      "description": "The PR number, for a direct CLI launch (--param args=<n>). Omit to use the current branch's PR." }
  ],
  "panel": {
    "description": "Show CI status, review verdict, and adjudication state for a PR",
    "hint": "[pr-number] (defaults to the current branch's PR)"
  },
  "phases": [
    {
      "id": "info",
      "tasks": [{
        "id": "show-pr",
        "reads": ["__panel_args__"],
        "steps": [{
          "id": "show-pr-step",
          "kind": "procedural.shell",
          "config": {
            "command": "PR=\"$DARKMUX_STEP_INPUT___PANEL_ARGS__\"\nif [ -z \"$PR\" ]; then PR=$(gh pr view --json number -q .number); fi\ngh pr view \"$PR\" --json number,title,headRefName,baseRefName,url,statusCheckRollup,comments -q '\n  . as $pr\n  | ($pr.statusCheckRollup // []) as $checks\n  | ($checks | map(select(.status==\"COMPLETED\" and (.conclusion==\"SUCCESS\" or .conclusion==\"SKIPPED\" or .conclusion==\"NEUTRAL\" | not))) | length) as $failed\n  | ($checks | map(select(.status!=\"COMPLETED\")) | length) as $pending\n  | (if ($checks|length)==0 then \"NO CHECKS REPORTED\"\n     elif $failed>0 then \"FAILURE (\\($failed) check(s) failed)\"\n     elif $pending>0 then \"PENDING (\\($pending) check(s) still running)\"\n     else \"SUCCESS (\\($checks|length) checks, all conclusion==SUCCESS/SKIPPED/NEUTRAL)\" end) as $ci\n  | ([$pr.comments[]? | select(.body|test(\"Automated review\"))] | last) as $review\n  | (if $review == null then \"no darkmux review posted on this PR\"\n     elif ($review.body|test(\"Advisory, not a merge gate\")) then \"advisory only, no higher-tier adjudication recorded\"\n     else \"custom attribution present, read the comment before treating this as a higher-tier sign-off\" end) as $adjudication\n  | \"ci: \\($ci)\\nreview: \\(if $review==null then \"none\" else ($review.body|split(\"\\n\")[0]) end)\\nadjudication: \\($adjudication)\\npr: #\\($pr.number) \\($pr.headRefName) -> \\($pr.baseRefName)\\nurl: \\($pr.url)\"\n'\n"
          }
        }]
      }]
    }
  ]
}

The CI classification is an ALLOW-list, not a deny-list: a check only reads SUCCESS when its conclusion is one of SUCCESS/SKIPPED/NEUTRAL, and zero checks reads NO CHECKS REPORTED rather than a false SUCCESS. An unrecognized conclusion (GitHub's ACTION_REQUIRED, STALE, STARTUP_FAILURE, and anything added later) reads as a failure instead of silently passing. Known gap: statusCheckRollup mixes CheckRun objects (status/conclusion) with commit-status StatusContext objects (state only); a repo whose checks are commit statuses rather than GitHub Actions runs reads those entries as perpetually PENDING under this jq. Pending is the safe direction, but worth knowing.

Real output from this repo, run against the PR that shipped this page's fixes:

ci: SUCCESS (10 checks, all conclusion==SUCCESS/SKIPPED/NEUTRAL)
review: none
adjudication: no darkmux review posted on this PR
pr: #1778 acp/1685-pr-flow -> main
url: https://github.com/kstrat2001/darkmux/pull/1778

And against a real PR with no CI configured at all (#1749), the allow-list rewrite is what keeps this honest rather than a false green:

ci: NO CHECKS REPORTED
review: none
adjudication: no darkmux review posted on this PR
pr: #1749 formula-2.5.1 -> main
url: https://github.com/kstrat2001/darkmux/pull/1749

pr-approve.json and pr-merge.json: gather, then a gated act

/approve's real use is a colleague's PR: GitHub itself forbids self-approval, so this verb is for reviewing OTHER sessions' work. Both configs share the exact gather task above; only the second task differs. pr-approve.json's executor:

{
  "id": "post-approval",
  "depends_on": ["gather"],
  "steps": [{
    "id": "post-approval-step",
    "kind": "procedural.shell",
    "gate": "operator",
    "config": {
      "command": "PR=$(echo \"$DARKMUX_STEP_INPUT_GATHER\" | grep '^pr:' | sed -E 's/^pr: #([0-9]+).*/\\1/')\n[ -n \"$PR\" ] || exit 1\ngh pr review \"$PR\" --approve --body \"Approved via darkmux panel.\""
    }
  }]
}

pr-merge.json's executor:

{
  "id": "do-merge",
  "depends_on": ["gather"],
  "steps": [{
    "id": "do-merge-step",
    "kind": "procedural.shell",
    "gate": "operator",
    "config": {
      "command": "PR=$(echo \"$DARKMUX_STEP_INPUT_GATHER\" | grep '^pr:' | sed -E 's/^pr: #([0-9]+).*/\\1/')\n[ -n \"$PR\" ] || exit 1\ngh pr merge \"$PR\" --squash"
    }
  }]
}

Both extract the PR number back out of gather's OWN already-fetched output (a single source of truth: no second gh pr view call, and the number the executor acts on is provably the same one the dialog just showed) rather than re-reading the raw panel args. The full configs (id, schema_version, cmd, inputs, panel) wrap around these two tasks the exact same way pr-info.json does above.

The [ -n "$PR" ] || exit 1 line guards against an empty extraction: gh pr view "" and gh pr merge "" --squash both silently fall back to resolving the CURRENT branch's PR rather than erroring, so if you tune gather's output format and the grep/sed pattern above stops matching, this line is what stands between "nothing happened" and "the session branch's own PR got merged instead of the one the dialog showed."

Honest limits