agentic-runner 3.9.0__tar.gz → 3.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/activities.py +314 -12
  4. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/callback.py +29 -1
  5. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/cli.py +16 -0
  6. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/git/contracts.py +15 -0
  7. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/git/fake_workspace.py +13 -0
  8. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/git/workspace.py +31 -0
  9. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/fastapi_client.py +18 -2
  10. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/.gitignore +0 -0
  11. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/LICENSE +0 -0
  12. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/README.md +0 -0
  13. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/pyproject.toml +0 -0
  14. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/attempts.py +0 -0
  15. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/auth_mode.py +0 -0
  16. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/build.py +0 -0
  17. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/child_watcher.py +0 -0
  18. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/cli_floor.py +0 -0
  19. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/config.py +0 -0
  20. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/credentials.py +0 -0
  21. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/device_login_activities.py +0 -0
  22. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/egress.py +0 -0
  23. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/harness_self_test.py +0 -0
  24. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/heartbeat_link.py +0 -0
  25. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/hooks.py +0 -0
  26. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/host_store.py +0 -0
  27. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/__init__.py +0 -0
  28. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  29. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  30. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  31. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  32. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  33. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  34. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  35. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  36. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/lifecycle.py +0 -0
  37. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/llm_proxy.py +0 -0
  38. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/mcp.py +0 -0
  39. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/message_store.py +0 -0
  40. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/oauth_connectors.py +0 -0
  41. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/private_state.py +0 -0
  42. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/py.typed +0 -0
  43. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  44. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/registration.py +0 -0
  45. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/runtime/__init__.py +0 -0
  46. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  47. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/sealed_box.py +0 -0
  48. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/service.py +0 -0
  49. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/__init__.py +0 -0
  50. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/__main__.py +0 -0
  51. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/control_plane.py +0 -0
  52. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/plugin.py +0 -0
  53. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  54. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/testing/workflow.py +0 -0
  55. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/tiny_http.py +0 -0
  56. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/triage_activities.py +0 -0
  57. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/usage_windows.py +0 -0
  58. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/user_sources.py +0 -0
  59. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/__init__.py +0 -0
  60. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  61. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  62. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  63. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  64. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  65. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  66. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/command_policy.py +0 -0
  67. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  68. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  69. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  70. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  71. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  72. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/settings.py +0 -0
  73. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workers/skills.py +0 -0
  74. {agentic_runner-3.9.0 → agentic_runner-3.11.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.9.0
3
+ Version: 3.11.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.9.0"
10
+ __version__ = "3.11.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -64,6 +64,8 @@ from agentic_runner.callback import (
64
64
  MessageListResponse,
65
65
  MessageSendRequest,
66
66
  MessageSendResponse,
67
+ RepoRequest,
68
+ RepoResponse,
67
69
  VerbRequest,
68
70
  VerbResponse,
69
71
  )
@@ -90,6 +92,7 @@ from agentic_runner.hooks import (
90
92
  )
91
93
  from agentic_runner.integrations.git.contracts import (
92
94
  AdoptWorkspaceRequest,
95
+ CheckoutDefaultBranchRequest,
93
96
  CheckoutWorkBranchRequest,
94
97
  CloneWorkspaceRequest,
95
98
  CommitAllRequest,
@@ -182,6 +185,7 @@ from agentic_runner_contracts.activity_io import (
182
185
  MemberDirectiveInput,
183
186
  MemberDirectiveOutput,
184
187
  OwnerConfirmationPending,
188
+ ProductBinding,
185
189
  ProposedLesson,
186
190
  PullRequestCloseInput,
187
191
  PullRequestCloseOutput,
@@ -244,6 +248,7 @@ from agentic_runner_contracts.github_port import (
244
248
  ReviewRequest,
245
249
  )
246
250
  from agentic_runner_contracts.grants import (
251
+ BRANCH_VERB,
247
252
  CHANNEL_READ_VERB,
248
253
  CHANNEL_RESOURCE_TYPE,
249
254
  CHANNEL_WRITE_VERB,
@@ -252,6 +257,7 @@ from agentic_runner_contracts.grants import (
252
257
  PR_OPEN_VERB,
253
258
  PR_REVIEW_VERB,
254
259
  PUSH_VERB,
260
+ READ_VERB,
255
261
  REPO_RESOURCE_TYPE,
256
262
  UNENFORCED_SNAPSHOT,
257
263
  Decision,
@@ -264,6 +270,7 @@ from agentic_runner_contracts.routing import (
264
270
  RoutingRefusedError,
265
271
  RunnerRoutingIdentity,
266
272
  assert_routed,
273
+ selector_satisfied,
267
274
  )
268
275
  from agentic_runner_contracts.runtime_context import (
269
276
  EGRESS_ALLOW_LIST_KEY,
@@ -287,6 +294,12 @@ _MERGE_ERROR_SUMMARY_LIMIT = 512
287
294
 
288
295
  _GRANT_EVALUATION_SOURCE = "ralph.grant_evaluation"
289
296
 
297
+ # ADR-0018 §5: the verbs that write to a repository, and so may touch only the Product a
298
+ # Work Record is bound to. A read ranges over every repository in reach.
299
+ _REPOSITORY_WRITE_VERBS = frozenset(
300
+ {BRANCH_VERB, PUSH_VERB, PR_OPEN_VERB, PR_REVIEW_VERB, PR_MERGE_VERB, PR_COMMENT_VERB}
301
+ )
302
+
290
303
  _DEFAULT_CLI_KIND = "codex_cli"
291
304
 
292
305
  _CONTRACT_WIPE_SOURCE = "ralph.contract_wipe"
@@ -504,6 +517,11 @@ class RunnerRalphFastApiClient(Protocol):
504
517
  async def get_question(self, question_id: str) -> dict[str, Any]:
505
518
  """A Question and its answer, for the Directive its hold wakes (PRD issue 60)."""
506
519
 
520
+ async def bind_product(
521
+ self, work_record_id: str, *, repository: str, base_branch: str
522
+ ) -> dict[str, Any]:
523
+ """The first `repo.branch` binds the Work Record's Product (ADR-0018 §5)."""
524
+
507
525
  async def get_directive_usage(
508
526
  self,
509
527
  work_record_id: str,
@@ -1206,6 +1224,49 @@ def _with_question[Output: (BranchPullRequestOutput, FixDirectiveOutput)](
1206
1224
  return replace(output, question=asked[0]) if asked else output
1207
1225
 
1208
1226
 
1227
+ def _with_binding(output: FixDirectiveOutput, bound: list[ProductBinding]) -> FixDirectiveOutput:
1228
+ """The Product the attempt's `repo branch` bound rides out on the output (ADR-0018 §5),
1229
+ and the bound repository's checkout is the Workspace from here on (§7)."""
1230
+
1231
+ if not bound or not output.workspace_path:
1232
+ return output
1233
+ binding = bound[0]
1234
+ return replace(
1235
+ output,
1236
+ product_binding=binding,
1237
+ workspace_path=str(_repository_checkout(Path(output.workspace_path), binding.repository)),
1238
+ )
1239
+
1240
+
1241
+ def _repository_checkout(workspace_path: Path, repository: str) -> Path:
1242
+ """Where an Organisation-scoped Work Record checks ``repository`` out (ADR-0018 §7).
1243
+
1244
+ Beneath its own Workspace, keyed on the repository's name. The Contract owns that
1245
+ directory and its Directive is running while the Runner clones, so a component it
1246
+ turned into a symlink is refused rather than followed out of the Workspace.
1247
+ """
1248
+
1249
+ owner, name = _split_repo(repository)
1250
+ root = workspace_path.resolve()
1251
+ checkout = root / owner / name
1252
+ for component in (root / owner, checkout):
1253
+ if component.is_symlink():
1254
+ raise GitWorkspacePolicyError(f"'{component}' is a symlink; refusing to check out")
1255
+ return checkout
1256
+
1257
+
1258
+ def _bound_checkout(workspace_path: Path, carried: str) -> Path:
1259
+ """The Workspace a Directive after binding runs in: the bound checkout the previous
1260
+ Directive handed back, when it lies beneath this Work Record's own Workspace."""
1261
+
1262
+ if not carried:
1263
+ return workspace_path
1264
+ checkout = Path(carried).resolve()
1265
+ if workspace_path.resolve() in checkout.parents and checkout.is_dir():
1266
+ return checkout
1267
+ return workspace_path
1268
+
1269
+
1209
1270
  def _question_prompt(question: Mapping[str, Any]) -> str:
1210
1271
  """What the woken Agent knows of its Question: the text, and the answer or its
1211
1272
  absence -- and, on silence, the two moves it has (PRD issue 60, 19: nothing ends on
@@ -1948,10 +2009,13 @@ class RunnerRalphActivities:
1948
2009
  )
1949
2010
  contract_id = runtime_state.contract_id
1950
2011
  await self._record_uid_allocation(request.work_record_id, contract_id)
1951
- workspace_path = self._prepare_workspace(
1952
- workspace_root=workspace_root,
1953
- contract_id=contract_id,
1954
- work_record_id=request.work_record_id,
2012
+ workspace_path = _bound_checkout(
2013
+ self._prepare_workspace(
2014
+ workspace_root=workspace_root,
2015
+ contract_id=contract_id,
2016
+ work_record_id=request.work_record_id,
2017
+ ),
2018
+ request.workspace_path,
1955
2019
  )
1956
2020
  runtime_state = replace(runtime_state, workspace_path=workspace_path)
1957
2021
  attempt = await attempt_stack.enter_async_context(
@@ -2524,6 +2588,7 @@ class RunnerRalphActivities:
2524
2588
  reserve_max_tokens: int | None = None,
2525
2589
  mcp_plan: McpPlan | None = None,
2526
2590
  asked: list[QuestionAsked] | None = None,
2591
+ bound: list[ProductBinding] | None = None,
2527
2592
  ) -> AsyncIterator[_DirectiveAttempt]:
2528
2593
  """Open one attempt's hook lifecycle and its callback socket (PRD issue 45).
2529
2594
 
@@ -2588,6 +2653,7 @@ class RunnerRalphActivities:
2588
2653
  workspace_path,
2589
2654
  directive_number=directive_number,
2590
2655
  asked=asked,
2656
+ bound=bound,
2591
2657
  ),
2592
2658
  uid=uid,
2593
2659
  )
@@ -3091,6 +3157,7 @@ class RunnerRalphActivities:
3091
3157
  *,
3092
3158
  directive_number: int = 0,
3093
3159
  asked: list[QuestionAsked] | None = None,
3160
+ bound: list[ProductBinding] | None = None,
3094
3161
  ) -> CallbackHandlers:
3095
3162
  """What ``agentic-runner annotate | artifact upload | verb`` actually do.
3096
3163
 
@@ -3154,7 +3221,11 @@ class RunnerRalphActivities:
3154
3221
  async def verb(request: VerbRequest) -> VerbResponse:
3155
3222
  try:
3156
3223
  decision = await self._evaluate_verb(
3157
- work_record_id, state, verb=request.verb, identifier=request.resource
3224
+ work_record_id,
3225
+ state,
3226
+ verb=request.verb,
3227
+ identifier=request.resource,
3228
+ bound_product=bound[0].product_id if bound else None,
3158
3229
  )
3159
3230
  except SeamUnavailableError as error:
3160
3231
  # The Directive asked over its own socket, so it is answered rather than
@@ -3321,6 +3392,18 @@ class RunnerRalphActivities:
3321
3392
  addressed_to=str(answer.get("addressed_to") or ""),
3322
3393
  )
3323
3394
 
3395
+ bindings: list[ProductBinding] = [] if bound is None else bound
3396
+
3397
+ async def repo_read(request: RepoRequest) -> RepoResponse:
3398
+ return await self._repo_callback(
3399
+ work_record_id, state, workspace_path, request, verb=READ_VERB, bound=bindings
3400
+ )
3401
+
3402
+ async def repo_branch(request: RepoRequest) -> RepoResponse:
3403
+ return await self._repo_callback(
3404
+ work_record_id, state, workspace_path, request, verb=BRANCH_VERB, bound=bindings
3405
+ )
3406
+
3324
3407
  return CallbackHandlers(
3325
3408
  annotate=annotate,
3326
3409
  artifact=artifact,
@@ -3328,6 +3411,8 @@ class RunnerRalphActivities:
3328
3411
  message_send=message_send,
3329
3412
  message_list=message_list,
3330
3413
  ask=ask,
3414
+ repo_read=repo_read,
3415
+ repo_branch=repo_branch,
3331
3416
  )
3332
3417
 
3333
3418
  def _open_store(self, state: _RuntimeContextState) -> tuple[MessageStore | None, str]:
@@ -3673,6 +3758,7 @@ class RunnerRalphActivities:
3673
3758
  verb: str,
3674
3759
  identifier: str,
3675
3760
  resource_type: str = REPO_RESOURCE_TYPE,
3761
+ bound_product: str | None = None,
3676
3762
  ) -> VerbDecision:
3677
3763
  """The one local Grant evaluation, and the Evidence Event that records it.
3678
3764
 
@@ -3686,19 +3772,228 @@ class RunnerRalphActivities:
3686
3772
  await self._require_live_link(
3687
3773
  work_record_id, state, verb=verb, identifier=identifier, resource_type=resource_type
3688
3774
  )
3775
+ snapshot = self._current_snapshot(state)
3689
3776
  decision = decide_verb(
3690
- self._current_snapshot(state),
3777
+ snapshot,
3691
3778
  verb=verb,
3692
3779
  identifier=identifier,
3693
3780
  resource_type=resource_type,
3694
3781
  )
3782
+ evidence = decision.evidence()
3783
+ refusal = (
3784
+ None
3785
+ if decision.decision is Decision.DENY or resource_type != REPO_RESOURCE_TYPE
3786
+ else self._product_scope_refusal(
3787
+ state,
3788
+ snapshot,
3789
+ verb=verb,
3790
+ identifier=identifier,
3791
+ bound_product=bound_product or state.product_id,
3792
+ )
3793
+ )
3794
+ if refusal is not None:
3795
+ reason, detail = refusal
3796
+ decision = replace(
3797
+ decision,
3798
+ decision=Decision.DENY,
3799
+ reason=reason,
3800
+ deciding_link=None,
3801
+ deciding_entry=None,
3802
+ )
3803
+ evidence = {**decision.evidence(), **detail}
3695
3804
  await self._fastapi_client.append_evidence(
3696
3805
  work_record_id,
3697
3806
  source=_GRANT_EVALUATION_SOURCE,
3698
- payload=decision.evidence(),
3807
+ payload=evidence,
3699
3808
  )
3700
3809
  return decision
3701
3810
 
3811
+ def _product_scope_refusal(
3812
+ self,
3813
+ state: _RuntimeContextState,
3814
+ snapshot: GrantSnapshot,
3815
+ *,
3816
+ verb: str,
3817
+ identifier: str,
3818
+ bound_product: str | None,
3819
+ ) -> tuple[str, dict[str, object]] | None:
3820
+ """Why a verb the Grant allows is still refused for the Product it touches.
3821
+
3822
+ Two rules on top of the Grant (ADR-0018), neither of which widens anything:
3823
+
3824
+ - §5: once a Work Record is bound, a write to a repository of a *second* Product
3825
+ is refused -- one branch and one pull request per Work Record (ADR-0012), and
3826
+ work across Products is an Epic, so the remedy is a child on that Product.
3827
+ - §6: before binding, the Runner was routed on the Contract's selector alone, so
3828
+ before any verb on a repository it checks its own tags against the selector of
3829
+ the Product that owns it, and fails closed. After binding, routing carries the
3830
+ Product's selector and ``assert_routed`` already holds it.
3831
+
3832
+ ``None`` when neither applies, including a repository no registry row in the
3833
+ snapshot names a Product for: the Grant has already answered that one.
3834
+ """
3835
+
3836
+ product_id = snapshot.product_of(identifier=identifier)
3837
+ if product_id is None:
3838
+ return None
3839
+ if bound_product and verb in _REPOSITORY_WRITE_VERBS and product_id != bound_product:
3840
+ return (
3841
+ f"this Work Record is bound to Product {bound_product}, and {identifier} "
3842
+ f"belongs to Product {product_id}: open a child Work Record on that Product "
3843
+ "with work.open",
3844
+ {
3845
+ "event": "work_record.second_product_refused",
3846
+ "bound_product_id": bound_product,
3847
+ "product_id": product_id,
3848
+ "remedy": "work.open",
3849
+ },
3850
+ )
3851
+ if state.repository or self._routing_identity is None:
3852
+ return None
3853
+ selector = snapshot.runner_selector_of(product_id)
3854
+ if selector is not None and selector_satisfied(selector, self._routing_identity.tags):
3855
+ return None
3856
+ return (
3857
+ f"this Runner's tags do not satisfy the Runner selector of Product {product_id}, "
3858
+ f"which owns {identifier}",
3859
+ {
3860
+ "event": "directive.repository_refused",
3861
+ "product_id": product_id,
3862
+ "runner_id": str(self._routing_identity.runner_id),
3863
+ "selector": dict(selector or {}),
3864
+ },
3865
+ )
3866
+
3867
+ async def _repo_callback(
3868
+ self,
3869
+ work_record_id: str,
3870
+ state: _RuntimeContextState,
3871
+ workspace_path: Path,
3872
+ request: RepoRequest,
3873
+ *,
3874
+ verb: str,
3875
+ bound: list[ProductBinding],
3876
+ ) -> RepoResponse:
3877
+ """``agentic-runner repo read | branch``: an Organisation-scoped Work Record's
3878
+ Workspace (ADR-0018 §5, §7).
3879
+
3880
+ Before binding, the Workspace is an empty directory and every repository read
3881
+ is checked out beneath it. `branch` cuts the Work Record's work branch in one of
3882
+ those checkouts and asks the control plane to bind the Product that owns the
3883
+ repository; that checkout is the Workspace from then on, and the next Directive
3884
+ is an ordinary Product-scoped one in it.
3885
+
3886
+ Both verbs go through `_evaluate_verb`, so they are evaluated, refused and
3887
+ recorded exactly as the activity seams are.
3888
+ """
3889
+
3890
+ def refused(reason: str, decision: str = Decision.DENY.value) -> RepoResponse:
3891
+ return RepoResponse(
3892
+ repository=request.repository, allowed=False, decision=decision, reason=reason
3893
+ )
3894
+
3895
+ if self._git_workspace is None or self._workspace_root is None:
3896
+ return refused("this Runner has no git workspace")
3897
+ if state.repository:
3898
+ return refused(
3899
+ f"this Work Record is bound to {state.repository}; its Workspace is that checkout"
3900
+ )
3901
+ if bound and verb == BRANCH_VERB and bound[0].repository == request.repository:
3902
+ return RepoResponse(
3903
+ repository=request.repository,
3904
+ allowed=True,
3905
+ decision=Decision.ALLOW.value,
3906
+ reason="already bound to this repository",
3907
+ path=str(_repository_checkout(workspace_path, request.repository)),
3908
+ product_id=bound[0].product_id,
3909
+ )
3910
+ try:
3911
+ decision = await self._evaluate_verb(
3912
+ work_record_id,
3913
+ state,
3914
+ verb=verb,
3915
+ identifier=request.repository,
3916
+ bound_product=bound[0].product_id if bound else None,
3917
+ )
3918
+ except SeamUnavailableError as error:
3919
+ return refused(error.reason)
3920
+ if not decision.allowed:
3921
+ return refused(decision.reason, decision.decision.value)
3922
+ if bound and verb == BRANCH_VERB:
3923
+ return refused(
3924
+ f"this Work Record is bound to {bound[0].repository}: one branch and one pull "
3925
+ "request per Work Record; open a child Work Record with work.open"
3926
+ )
3927
+ checkout = _repository_checkout(workspace_path, request.repository)
3928
+ git_workspace = self._git_workspace
3929
+
3930
+ async def checkout_default_branch() -> str:
3931
+ checked_out = await asyncio.to_thread(
3932
+ git_workspace.checkout_default_branch,
3933
+ CheckoutDefaultBranchRequest(
3934
+ repo_full_name=request.repository, workspace_path=checkout
3935
+ ),
3936
+ )
3937
+ return checked_out.base_branch
3938
+
3939
+ fresh = not checkout.is_dir()
3940
+ # On an existing checkout this re-registers it with the adapter, which a Runner
3941
+ # restart between two Directives would otherwise have forgotten.
3942
+ await asyncio.to_thread(
3943
+ git_workspace.clone_repository,
3944
+ CloneWorkspaceRequest(
3945
+ repo_full_name=request.repository,
3946
+ remote_url=_github_remote_url(request.repository),
3947
+ workspace_root=self._require_workspace_root(),
3948
+ workspace_path=checkout,
3949
+ ),
3950
+ )
3951
+ default_branch = await checkout_default_branch() if fresh else ""
3952
+ product_id = ""
3953
+ if verb == BRANCH_VERB:
3954
+ base_ref = request.base_ref
3955
+ if base_ref:
3956
+ await asyncio.to_thread(
3957
+ git_workspace.fetch_base_branch,
3958
+ FetchBranchRequest(
3959
+ repo_full_name=request.repository,
3960
+ workspace_path=checkout,
3961
+ base_branch=base_ref,
3962
+ ),
3963
+ )
3964
+ else:
3965
+ base_ref = default_branch or await checkout_default_branch()
3966
+ await asyncio.to_thread(
3967
+ git_workspace.checkout_work_branch,
3968
+ CheckoutWorkBranchRequest(
3969
+ repo_full_name=request.repository,
3970
+ workspace_path=checkout,
3971
+ base_branch=base_ref,
3972
+ work_branch=state.work_branch,
3973
+ ),
3974
+ )
3975
+ answer = await self._fastapi_client.bind_product(
3976
+ work_record_id, repository=request.repository, base_branch=base_ref
3977
+ )
3978
+ product_id = str(answer.get("product_id") or "")
3979
+ if answer.get("decision") != Decision.ALLOW.value or not product_id:
3980
+ return refused(str(answer.get("reason") or "the control plane refused to bind"))
3981
+ bound.append(
3982
+ ProductBinding(
3983
+ repository=request.repository, base_ref=base_ref, product_id=product_id
3984
+ )
3985
+ )
3986
+ if self._contract_isolation is not None:
3987
+ self._contract_isolation.hand_workspace_to_contract(state.contract_id, checkout)
3988
+ return RepoResponse(
3989
+ repository=request.repository,
3990
+ allowed=True,
3991
+ decision=decision.decision.value,
3992
+ reason=decision.reason,
3993
+ path=str(checkout),
3994
+ product_id=product_id,
3995
+ )
3996
+
3702
3997
  def _current_snapshot(self, state: _RuntimeContextState) -> GrantSnapshot:
3703
3998
  """The snapshot to decide *this* verb on, not the one the activity started with.
3704
3999
 
@@ -4890,13 +5185,16 @@ class RunnerRalphActivities:
4890
5185
  kind: _InPlaceDirective,
4891
5186
  ) -> FixDirectiveOutput:
4892
5187
  asked: list[QuestionAsked] = []
4893
- return _with_question(await self._run_in_place_turn(request, kind, asked), asked)
5188
+ bound: list[ProductBinding] = []
5189
+ ran = await self._run_in_place_turn(request, kind, asked, bound)
5190
+ return _with_binding(_with_question(ran, asked), bound)
4894
5191
 
4895
5192
  async def _run_in_place_turn(
4896
5193
  self,
4897
5194
  request: FixDirectiveInput | MemberDirectiveInput,
4898
5195
  kind: _InPlaceDirective,
4899
5196
  asked: list[QuestionAsked],
5197
+ bound: list[ProductBinding],
4900
5198
  ) -> FixDirectiveOutput:
4901
5199
  """One runtime turn in the Workspace the first Directive cloned, then push.
4902
5200
 
@@ -4977,6 +5275,7 @@ class RunnerRalphActivities:
4977
5275
  sandbox=self._contract_sandbox(state),
4978
5276
  mcp_plan=mcp_plan,
4979
5277
  asked=asked,
5278
+ bound=bound,
4980
5279
  )
4981
5280
  )
4982
5281
  # No checkout phase here: an in-place Directive runs in the Workspace the
@@ -5085,10 +5384,12 @@ class RunnerRalphActivities:
5085
5384
  )
5086
5385
  raise RuntimeError("Codex CLI fix directive failed")
5087
5386
  # A report's directory is no checkout (console-v2 issue 23): what the Agent
5088
- # wrote there stays there, and its report left as a Message.
5387
+ # wrote there stays there, and its report left as a Message. Nor is an
5388
+ # Organisation-scoped Work Record's before it binds (ADR-0018 §7): what it
5389
+ # changed in the checkout it bound is committed by the next Directive.
5089
5390
  git_evidence = (
5090
5391
  None
5091
- if state.kind == KIND_REPORT
5392
+ if state.kind == KIND_REPORT or not state.repository
5092
5393
  else self._git_workspace.collect_git_evidence(
5093
5394
  state.workspace_path,
5094
5395
  output_limit_bytes=_GIT_EVIDENCE_LIMIT_BYTES,
@@ -5426,7 +5727,8 @@ class RunnerRalphActivities:
5426
5727
  re-assembled tree is the work as it was last pushed, not a fresh branch off base.
5427
5728
 
5428
5729
  A report has no branch (console-v2 issue 23): its Workspace is the empty
5429
- directory alone, on its first Directive and after any re-route alike.
5730
+ directory alone, on its first Directive and after any re-route alike. So is an
5731
+ Organisation-scoped Work Record's before it binds a Product (ADR-0018 §7).
5430
5732
  """
5431
5733
 
5432
5734
  await self._assert_routed(request)
@@ -5441,7 +5743,7 @@ class RunnerRalphActivities:
5441
5743
  contract_id=contract_id,
5442
5744
  work_record_id=request.work_record_id,
5443
5745
  )
5444
- if state.kind == KIND_REPORT:
5746
+ if state.kind == KIND_REPORT or not state.repository:
5445
5747
  await self._fastapi_client.append_evidence(
5446
5748
  request.work_record_id,
5447
5749
  source=_ROUTING_EVIDENCE_SOURCE,
@@ -4,7 +4,7 @@ Buildkite's Job API, narrowed to what ADR-0011 §9 can defend. For each Directiv
4
4
  the Runner mints 32 random bytes as a bearer and opens a Unix socket **owned by the
5
5
  Contract's uid** (map ticket 17 A11); the Agent Runtime subprocess receives the socket
6
6
  path and the bearer in its environment and nothing else. ``agentic-runner annotate |
7
- artifact upload | verb`` speak to it.
7
+ artifact upload | verb | repo`` speak to it.
8
8
 
9
9
  What makes that not a credential: the token's only audience is this Runner's own socket,
10
10
  it is useless off the box, it dies with the attempt, and it is a *correlator* rather than
@@ -65,6 +65,8 @@ __all__ = [
65
65
  "MessageListResponse",
66
66
  "MessageSendRequest",
67
67
  "MessageSendResponse",
68
+ "RepoRequest",
69
+ "RepoResponse",
68
70
  "Route",
69
71
  "VerbRequest",
70
72
  "VerbResponse",
@@ -127,6 +129,28 @@ class VerbResponse(BaseModel):
127
129
  reason: str
128
130
 
129
131
 
132
+ class RepoRequest(BaseModel):
133
+ """``agentic-runner repo read | branch`` (ADR-0018 §5, §7): one repository, by name.
134
+
135
+ ``base_ref`` is for `branch` only; empty cuts the work branch from the repository's
136
+ default branch.
137
+ """
138
+
139
+ repository: str = Field(max_length=200, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
140
+ base_ref: str = Field(default="", max_length=255)
141
+
142
+
143
+ class RepoResponse(BaseModel):
144
+ """The seam's verdict and, when allowed, where the checkout is in the Workspace."""
145
+
146
+ repository: str
147
+ allowed: bool
148
+ decision: str
149
+ reason: str
150
+ path: str = ""
151
+ product_id: str = ""
152
+
153
+
130
154
  class MessageSendRequest(BaseModel):
131
155
  """``agentic-runner message send`` (PRD issue 52): the envelope minus what the Runner
132
156
  fills in itself -- sender, Work Record, id and time are the attempt's, never the
@@ -200,6 +224,8 @@ ROUTES: Final[dict[tuple[str, str], Route]] = {
200
224
  ("POST", "/v0/message/send"): Route(MessageSendRequest, MessageSendResponse, "message_send"),
201
225
  ("POST", "/v0/message/list"): Route(MessageListRequest, MessageListResponse, "message_list"),
202
226
  ("POST", "/v0/ask"): Route(AskRequest, AskResponse, "ask"),
227
+ ("POST", "/v0/repo/read"): Route(RepoRequest, RepoResponse, "repo_read"),
228
+ ("POST", "/v0/repo/branch"): Route(RepoRequest, RepoResponse, "repo_branch"),
203
229
  }
204
230
 
205
231
 
@@ -213,6 +239,8 @@ class CallbackHandlers:
213
239
  message_send: Callable[[MessageSendRequest], Awaitable[MessageSendResponse]]
214
240
  message_list: Callable[[MessageListRequest], Awaitable[MessageListResponse]]
215
241
  ask: Callable[[AskRequest], Awaitable[AskResponse]]
242
+ repo_read: Callable[[RepoRequest], Awaitable[RepoResponse]]
243
+ repo_branch: Callable[[RepoRequest], Awaitable[RepoResponse]]
216
244
 
217
245
 
218
246
  class CallbackError(RuntimeError):
@@ -233,6 +233,17 @@ def main(argv: Sequence[str] | None = None) -> int:
233
233
  )
234
234
  ask.add_argument("text", nargs="?", help="the question; omitted reads stdin")
235
235
 
236
+ repo = subcommands.add_parser(
237
+ "repo",
238
+ help="check out a repository in reach; `branch` binds an Organisation-wide Work Record "
239
+ "to its Product (ADR-0018)",
240
+ )
241
+ repo.add_argument("action", choices=("read", "branch"))
242
+ repo.add_argument("repository", help="owner/name")
243
+ repo.add_argument(
244
+ "--base", default="", help="`branch` only: the base branch; default the repository's"
245
+ )
246
+
236
247
  subcommands.add_parser("config", help="print the resolved configuration (flag > env > file)")
237
248
 
238
249
  arguments = parser.parse_args(argv)
@@ -391,6 +402,11 @@ def _request(arguments: argparse.Namespace) -> tuple[str, dict[str, object]]:
391
402
  return "/v0/ask", {
392
403
  "text": arguments.text if arguments.text is not None else sys.stdin.read()
393
404
  }
405
+ if arguments.command == "repo":
406
+ return f"/v0/repo/{arguments.action}", {
407
+ "repository": arguments.repository,
408
+ "base_ref": arguments.base,
409
+ }
394
410
  if arguments.command == "message":
395
411
  if arguments.action == "list":
396
412
  return "/v0/message/list", {"channel_id": arguments.channel}
@@ -60,6 +60,14 @@ class FetchBranchResult:
60
60
  base_branch: str
61
61
 
62
62
 
63
+ @dataclass(frozen=True)
64
+ class CheckoutDefaultBranchRequest:
65
+ """Request to check out ``origin``'s default branch, detached, in a fresh clone."""
66
+
67
+ repo_full_name: str
68
+ workspace_path: Path
69
+
70
+
63
71
  @dataclass(frozen=True)
64
72
  class CheckoutWorkBranchRequest:
65
73
  """Request to create or reset a generated work branch from the base branch."""
@@ -172,6 +180,13 @@ class GitWorkspace(Protocol):
172
180
  def fetch_base_branch(self, request: FetchBranchRequest) -> FetchBranchResult:
173
181
  """Fetch the requested base branch into a workspace."""
174
182
 
183
+ def checkout_default_branch(self, request: CheckoutDefaultBranchRequest) -> FetchBranchResult:
184
+ """Check out the remote's default branch detached; its name is the result's base.
185
+
186
+ A repository an Organisation-scoped Work Record reads has no base branch of its
187
+ own yet (ADR-0018 §7): the clone's ``origin/HEAD`` is the one it reads.
188
+ """
189
+
175
190
  def checkout_work_branch(
176
191
  self,
177
192
  request: CheckoutWorkBranchRequest,
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from agentic_runner.integrations.git.contracts import (
7
7
  AdoptWorkspaceRequest,
8
+ CheckoutDefaultBranchRequest,
8
9
  CheckoutWorkBranchRequest,
9
10
  CheckoutWorkBranchResult,
10
11
  CloneWorkspaceRequest,
@@ -63,6 +64,7 @@ class FakeGitWorkspace:
63
64
  self._next_commit_number = 1
64
65
  self._status_evidence = status_evidence
65
66
  self._diff_evidence = diff_evidence
67
+ self.default_branch = "main"
66
68
 
67
69
  @property
68
70
  def calls(self) -> tuple[FakeGitWorkspaceCall, ...]:
@@ -146,6 +148,17 @@ class FakeGitWorkspace:
146
148
  base_branch=request.base_branch,
147
149
  )
148
150
 
151
+ def checkout_default_branch(self, request: CheckoutDefaultBranchRequest) -> FetchBranchResult:
152
+ record = self._get_workspace(request.workspace_path)
153
+ self._require_same_repo(record, request.repo_full_name)
154
+ record.base_branch = self.default_branch
155
+ self._record_call("checkout_default_branch", request.repo_full_name, record.workspace_path)
156
+ return FetchBranchResult(
157
+ repo_full_name=request.repo_full_name,
158
+ workspace_path=record.workspace_path,
159
+ base_branch=self.default_branch,
160
+ )
161
+
149
162
  def checkout_work_branch(
150
163
  self,
151
164
  request: CheckoutWorkBranchRequest,
@@ -13,6 +13,7 @@ from uuid import UUID
13
13
 
14
14
  from agentic_runner.integrations.git.contracts import (
15
15
  AdoptWorkspaceRequest,
16
+ CheckoutDefaultBranchRequest,
16
17
  CheckoutWorkBranchRequest,
17
18
  CheckoutWorkBranchResult,
18
19
  CloneWorkspaceRequest,
@@ -390,6 +391,36 @@ class LocalGitWorkspace:
390
391
  base_branch=base_branch,
391
392
  )
392
393
 
394
+ def checkout_default_branch(self, request: CheckoutDefaultBranchRequest) -> FetchBranchResult:
395
+ record = self._get_workspace(request.workspace_path)
396
+ self._require_same_repo(record, request.repo_full_name)
397
+ # Asked of the remote, not read off ``refs/remotes/origin/HEAD``: a clone sets that
398
+ # ref only when the remote's HEAD resolved at clone time.
399
+ advertised = self._run_git(
400
+ ["ls-remote", "--symref", "origin", "HEAD"],
401
+ cwd=record.workspace_path,
402
+ record=record,
403
+ ).stdout
404
+ remote_head = next(
405
+ (
406
+ line.split()[1]
407
+ for line in advertised.splitlines()
408
+ if line.startswith("ref: ") and line.endswith("\tHEAD")
409
+ ),
410
+ "",
411
+ )
412
+ base_branch = validate_base_branch(remote_head.removeprefix("refs/heads/"))
413
+ self._run_git(
414
+ ["checkout", "--detach", f"refs/remotes/origin/{base_branch}"],
415
+ cwd=record.workspace_path,
416
+ record=record,
417
+ )
418
+ return FetchBranchResult(
419
+ repo_full_name=request.repo_full_name,
420
+ workspace_path=record.workspace_path,
421
+ base_branch=base_branch,
422
+ )
423
+
393
424
  def checkout_work_branch(
394
425
  self,
395
426
  request: CheckoutWorkBranchRequest,
@@ -75,8 +75,9 @@ class RunnerFastApiClient:
75
75
  for *bookkeeping* — its activities do the thing and return facts, and the workflow
76
76
  records them through platform activities (``PlatformRalphActivities``). What is left
77
77
  here is what an activity genuinely cannot do without: the Work Record's runtime
78
- context, the Directive's metered usage, Questions and Owner Confirmations, and the
79
- Evidence its verb seams write. Every path is on the public Runner surface; the
78
+ context, the Directive's metered usage, Questions and Owner Confirmations, the
79
+ Product binding the `repo.branch` seam asks for (ADR-0018 §5), and the Evidence its
80
+ verb seams write. Every path is on the public Runner surface; the
80
81
  authoritative list of the routes a Runner calls is this class plus
81
82
  ``registration.py``'s constants (tests/integration/test_runner_api_surface.py).
82
83
 
@@ -196,6 +197,21 @@ class RunnerFastApiClient:
196
197
  request_payload,
197
198
  )
198
199
 
200
+ async def bind_product(
201
+ self, work_record_id: str, *, repository: str, base_branch: str
202
+ ) -> dict[str, Any]:
203
+ """Bind an Organisation-scoped Work Record to ``repository``'s Product (ADR-0018 §5).
204
+
205
+ The control plane resolves the Product from its own registry, sets it and records
206
+ `work_record.product_bound` in one transaction; ``decision`` is ``deny`` with a
207
+ ``reason`` when the repository is out of reach or a second Product's.
208
+ """
209
+
210
+ return await self._post(
211
+ f"{self._prefix}/work-records/{_path_segment(work_record_id)}/product-binding",
212
+ {"repository": repository, "base_branch": base_branch},
213
+ )
214
+
199
215
  async def transition_work_record(
200
216
  self,
201
217
  work_record_id: str,
File without changes