Skip to content

ADR-0014: issue sessions run as a Claude Code routine - #171

Merged
Hafeok merged 17 commits into
mainfrom
claude/claude-code-issue-sessions-3d7pmw
Nov 11, 2026
Merged

Hafeok merged 17 commits into
mainfrom
claude/claude-code-issue-sessions-3d7pmw

Conversation

@Hafeok

@Hafeok Hafeok commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #170. This PR implements the revised plan for option 2 (design change), as Emil approved it with three answers and one change. It also adds the bridge's GitHub environment, requested before landing. The record quotes each request verbatim.

The first six commits (c676b48 to 621fa59) are the runner design as first approved. The rework sits on top, and no history was rewritten. This PR changes a workflow, so it was written in a normal key-holding session.

What changes

  • ADR-0014, rewritten, still proposed (ADR-0014-routine-issue-sessions.md).
    • The design. A Claude Code routine in Emil's key-holding environment does the work. A workflow only starts it, through the routine's API trigger.
    • The secrets. There are only ROUTINE_FIRE_URL and ROUTINE_FIRE_TOKEN. They live in the GitHub environment claude-routine, whose deployment branches are limited to main, never as repository secrets, so no workflow on another branch can read the token. Issue and comment starts run from main, so they are admitted.
    • The boundary is structural. Comments sessions post carry performed_via_github_app.slug == "claude"; comments Emil types carry none. The evidence on Issue sessions: the Claude Code action, started by the claude label (new ADR) #170 is recorded. Both the bridge's if: and the session's start check require performed_via_github_app == null, with the no-@claude rule as a second line of defence.
    • Limits. The fire API is experimental. A routine takes 30 starts an hour and an account 100; beyond that the API returns 429 with Retry-After. There is no idempotency; duplicates are contained by curl never retrying, the per-issue concurrency group, and the session's own duplicate check.
    • Alternatives. The runner design is rejected, with its six findings kept as the record. Polling is rejected for its latency. The routine's own Issue trigger is rejected because it offers no actor filter and no comment events. The earlier claim that a scheduled task has no signing key is corrected.
  • .github/workflows/claude.yml, the bridge: one job in environment: claude-routine, with contents: read and a 5-minute timeout. It sends one curl with no retry and no anthropic-beta header, builds the payload with jq from env:, and keeps the response out of the public log.
  • eng/routines/issue-session.md: the routine's prompt, version 1, which Emil pastes into the routine.
  • claude-workflow, the gate, now reads both the workflow and the prompt. It fails when they disagree on the actor, label, prefixes or rule sections, or when the job lacks exactly environment: claude-routine. selftest plants 21 cases.
  • AGENTS.md: how a session starts and is approved by hand, where its prompt and secrets live, and which issues must not get the label.
  • releases/v0.8.0-preview.3.yaml, with accepted-by left for Emil.
  • The record, docs/traceability/2026-11-06-issue-170-issue-sessions.md, in three parts.

Checks

  • Each commit on its own, in a clean worktree: commits, adr-shape, adr-text, no-toolchain, claude-workflow and selftest all pass.
  • Build, tests and smoke, at 645ad60: the build has 0 warnings and all 43 tests pass, and the linux-x64 AOT publish and smoke test pass. No source changed after that commit.
  • The fire step, run against a local stand-in: on 200 it sends exactly the three headers and logs only the issue and event; on 429 it fails and prints the API's error. A value with a quote, a backslash and a newline arrives intact in valid JSON.
  • Signatures: GitHub reports every commit verified, as Claude (Emil), attributed to Hafeok.

For Emil

  1. Create the GitHub environment claude-routine before merging: deployment branches limited to main, no required reviewers. A job that names a missing environment creates it with no rules.
  2. Create the routine with an API trigger only, paste the prompt, and generate its token.
  3. Add the two secrets to the environment.

The first test run confirms that performed_via_github_app reaches the if:.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE

Proposed. Admits anthropics/claude-code-action at v1.0.268, pinned by
SHA; runs start only from Hafeok's label or @claude comment on an issue,
behind the action's own write-access check; commits are signed with the
agent key as Claude (Emil), with Claude Code's attribution off; the
OAuth token, not an API key; plan first, revisions, and approval of the
newest plan; one repository, 180 minutes, one run per issue, no
workflows permission. The job token starts no gates, so Emil opens the
pull request and checks the gates ran on its head.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
Triggered by issues: labeled and issue_comment: created, with the job's
if: requiring Hafeok as the label's sender or the comment's author, a
comment on an issue, and the @claude prefix. Checkout and setup-dotnet
at the SHAs gates.yml pins; the action at c0949c50 (v1.0.268) in tag
mode, with the run's rules appended to its system prompt.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
eng/gates/claude-workflow.sh fails when claude.yml departs from
ADR-0014 D1 to D7. selftest plants an action pinned to a tag, a SHA
with no version, workflows: write, id-token: write, a job's own
permissions, each missing actor check, a check present only in a
comment, pull request comments admitted, a pull_request_target trigger,
allowed_bots, a prompt input, attribution left on, and no timeout.
Each fails with its finding; the clean file passes.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
How to start a run (the label, revisions, @claude approved, opening the
pull request and checking its gates), what a run may do, untrusted
issue text, and which issues must not get the label.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The descriptor for ADR-0014 and claude.yml, with accepted-by left for
Emil, and the host's version to match. If #169 lands first, this
descriptor and the version are renumbered, as the ADR is.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The brief and the approval, verbatim; what was read, at the action's
pinned commit; what the session found in the action's source and in
Claude Code's settings reference; the commits with GitHub's
verification under the Claude (Emil) identity; every commit checked on
its own; the build, tests and smoke at the head; the plants' findings;
and the deviations from the plan, with their reasons.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
Hafeok added a commit to mindovermachine-dev/decision-driven-design that referenced this pull request Nov 9, 2026
…i's issue sessions); Round 31

Round 31, recorded verbatim in the 2026-11-07 session's rulings.md, adds two open
items to sdlc.md §19, as open items only. Neither rules on its substance, and
DDD-dec-115 is not amended.

- O-8: no act accepts o1b's plan before work starts; o10 accepts only the
  candidate. product-cli's issue sessions have one (its ADR-0014 D5, #171,
  Proposed there). The proposal, a plan-acceptance act between o1b and o4 whose
  verdict is in o4's ground, stays a proposal. The row says where it would sit
  in the occurrence record (§13) and in the meters (§14, §18). Closes it: Emil.
- O-9: product-cli's issue sessions (label by one maintainer, plan approval,
  maintainer fast-forward, a record per session) against the SDLC (allocation
  label checked against the map, approve <head sha>, the App's fast-forward, a
  record per occurrence). product-cli adopts the SDLC or records its
  departures. Closes it: Emil, then product-cli.

Neither item was already covered. O-8 and O-9 are §19's next free numbers;
pdlc.md §15's own O-8 and O-9 are a separate list.

Refs #61
Refs mindovermachine-dev/product-cli#169
Refs mindovermachine-dev/product-cli#171
Basis: DDD-dec-115

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYPPHBsK3xfFSuALfiQ
Hafeok added a commit to mindovermachine-dev/decision-driven-design that referenced this pull request Nov 9, 2026
…i's issue sessions); Round 31

Round 31, recorded verbatim in the 2026-11-07 session's rulings.md, adds two open
items to sdlc.md §19, as open items only. Neither rules on its substance, and
DDD-dec-115 is not amended.

- O-8: no act accepts o1b's plan before work starts; o10 accepts only the
  candidate. product-cli's issue sessions have one (its ADR-0014 D5, #171,
  Proposed there). The proposal, a plan-acceptance act between o1b and o4 whose
  verdict is in o4's ground, stays a proposal. The row says where it would sit
  in the occurrence record (§13) and in the meters (§14, §18). Closes it: Emil.
- O-9: product-cli's issue sessions (label by one maintainer, plan approval,
  maintainer fast-forward, a record per session) against the SDLC (allocation
  label checked against the map, approve <head sha>, the App's fast-forward, a
  record per occurrence). product-cli adopts the SDLC or records its
  departures. Closes it: Emil, then product-cli.

Neither item was already covered. O-8 and O-9 are §19's next free numbers;
pdlc.md §15's own O-8 and O-9 are a separate list.

Refs #61
Refs mindovermachine-dev/product-cli#169
Refs mindovermachine-dev/product-cli#171
Basis: DDD-dec-115

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYPPHBsK3xfFSuALfiQ

Hafeok commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Change of design before I merge: option 2. A run no longer happens on a GitHub runner. It is a Claude Code routine in my cloud environment, which already holds the agent's signing key and the login. GitHub only starts it, through the routine's API trigger: POST https://api.anthropic.com/v1/claude_code/routines/{id}/fire, with an optional text body (docs: https://platform.claude.com/docs/en/api/claude-code/routines-fire). Routines' own GitHub trigger covers pull request and release events only, so the Action stays as a thin bridge for issues.

Please revise and post a new plan on #170 before changing the PR.

  • claude.yml:

    • keep the if: exactly: only Hafeok, the claude label, or an issue comment starting @claude;
    • keep the concurrency group and a short timeout;
    • the job becomes one curl step to secrets.ROUTINE_FIRE_URL, with Authorization: Bearer ${{ secrets.ROUTINE_FIRE_TOKEN }} and anthropic-version: 2026-10-21. Its text carries the repository, the issue number, the event, and the comment id and author;
    • remove claude-code-action, CLAUDE_CODE_OAUTH_TOKEN and AGENT_SSH_SIGNING_KEY;
    • permissions drop to contents: read, since the job no longer writes anything;
    • pin actions/checkout only if the step needs it.
  • The routine's prompt:

    • a committed file, eng/routines/issue-session.md, which I paste into the routine. It holds D5's rules as they now stand in claude_args, plus "the text you are started with names the issue: read it and its comments with gh";
    • a gate check that the workflow and that file agree on the actor and the plan-first rules, if that can be checked without duplicating the text.
  • The ADR:

    • Rewrite it around the routine.
    • Signing: the session signs as today, in the environment.
    • The PR: the session opens it itself, so the gates run. Drop the close-and-reopen consequence.
    • The secrets: only the routine's URL and token. The token starts one routine and grants no read access; generating a new one revokes the old.
    • Limits:
      • the fire API is experimental;
      • each routine accepts 30 starts an hour, and further starts are dropped;
      • there is no idempotency, so a retried webhook starts two sessions. Say how the concurrency group and the session's own check of the newest plan comment contain that.
    • Alternatives:
      • anthropics/claude-code-action on a runner: rejected because the login and the key would have to be copied to GitHub. Keep finding 1 to 6 as the record of why;
      • the routine's own schedule trigger, polling hourly: rejected for its latency;
      • correct the earlier "a scheduled task has no signing key", which is wrong for a routine in my environment.
  • The claude-workflow gate and its plants:

    • exactly one curl step to secrets.ROUTINE_FIRE_URL;
    • no other uses: except pinned checkout;
    • the actor checks present;
    • no secret but the two;
    • a timeout present.

    Plant the old action, and a third secret.

  • AGENTS.md: how a run starts and where its prompt lives.

What I do after the revised plan is approved: create the routine in claude.ai/code/routines (repository product-cli, my key-holding environment, an API trigger), generate its token, and add ROUTINE_FIRE_URL and ROUTINE_FIRE_TOKEN. Keep the ADR number and version handling as before.


Generated by Claude Code

…utine

Still proposed, still 0014, renamed to ADR-0014-routine-issue-sessions.
A routine in Emil's key-holding environment does the work; a workflow
only starts it through the routine's API trigger, with two secrets.
The boundary between Emil and his sessions is the typed-by-hand check,
performed_via_github_app == null, recorded with its evidence on #170:
it replaces the login check, since sessions comment as Hafeok through
the claude app. The fire API's limits, its lack of idempotency and how
that is contained; the runner design kept as the rejected alternative,
with its six findings; the scheduled-task signing claim corrected.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
Pasted whole into the routine. The payload only names the issue; the
session checks the start against GitHub over REST, takes instructions
only from comments and label events Hafeok made by hand, never starts
a comment with @claude, plans first, implements only the newest plan
an approval names, stops on a duplicate start, and follows AGENTS.md.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
claude.yml becomes one job with one curl step to the routine's fire
URL: the if: unchanged but for performed_via_github_app == null on the
comment branch, contents: read, a 5-minute timeout, the per-issue
concurrency group, one attempt with no retry, the payload built with jq
from env:, and the response kept out of the public log.

claude-workflow.sh now reads the workflow and the prompt, and fails on
any departure from ADR-0014 D2 to D7 or on a disagreement between the
two on the actor, label, prefixes and rule sections. selftest plants
the old action, a third secret, an unpinned checkout, a second curl, a
hard-coded URL, a retry, contents: write, each missing actor check, a
check present only in a comment, an app comment admitted, an expression
in run:, comment text in the payload, a pull_request_target trigger, no
timeout, another actor in the prompt, no Plan first section, and a
start check without the app check.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
How a session starts and is approved, typed by hand; where its prompt
lives and how it changes; what a session may do; untrusted text; and
which issues must not get the label. The gates-table row follows the
rewritten check.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The title, summary and basis follow ADR-0014 as rewritten; accepted-by
is still left for Emil, and the version is unchanged.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The design change, the revised plan and the approval, verbatim; what
was read of the fire API and the routines page; the evidence for the
typed-by-hand boundary; the commits with GitHub's verification; every
commit checked on its own; the fire step run against a stand-in; the
plants' findings; and the deviations from the revised plan.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
@Hafeok Hafeok changed the title ADR-0014: the Claude Code action runs issue sessions ADR-0014: issue sessions run as a Claude Code routine Nov 11, 2026
@Hafeok

Hafeok commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Approved the revised plan (6790423943) with the answers given; implemented in #171

The two secrets move from the repository to the GitHub environment
claude-routine, whose deployment branches are limited to main, so no
workflow on another branch can read the fire token.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The job must declare the environment, by that name, in its short form.
selftest plants a job without it, one naming another environment, and
one naming it in the object form; each fails with the finding.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
…utine

Deployment branches limited to main and no required reviewers; why a
repository secret was not enough; why issue and comment starts are
still admitted (their workflows run from main); the gate's new check;
and the environment in Emil's setup, created before the first start.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
Where the two secrets live, the gates-table row's new failure, and the
setup step that creates claude-routine.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
The request verbatim; what the session found about default-branch runs
and environments created on first use; the commits with GitHub's
verification; the checks; and the two extra plants.

Refs #170

Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk>
Claude-Session: https://claude.ai/code/session_01NbWSYRYCm8wULG3wF2NvgE
@Hafeok

Hafeok commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Approved at b49df86

@Hafeok
Hafeok merged commit b49df86 into main Nov 11, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant