@markjaquith/agency 2.54.2 → 2.54.4

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.
package/README.md CHANGED
@@ -276,6 +276,19 @@ Execution-unit runners also receive `AGENCY_WRITABLE_CHECKOUT` with the
276
276
  authoritative writable checkout path.
277
277
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
278
278
  `AGENCY_PROMPT` is empty unless `--auto` is set.
279
+ Autonomous prompts begin `Agency worker launch target: <target>.`, carrying the
280
+ same canonical target as `AGENCY_TARGET`. This is the process-local fallback for
281
+ runner clients that attach to a long-lived process and lose launch environment
282
+ variables. A worker must verify either signal against `agency context . --json`
283
+ before acting; a matching worker performs the task directly and must not invoke
284
+ `agency work` for the same target. Managed guidance also fails safe for older
285
+ generated prompts when their document paths, current directory, and active valid
286
+ context all agree. Herdr is not part of this identity contract.
287
+ The managed OpenCode plugin validates the marker against Agency context, binds it
288
+ to the receiving OpenCode session, injects an explicit active-worker system
289
+ instruction, and restores Agency identity for that session's shell environment.
290
+ This session bridge is necessary because an OpenCode client can attach to a
291
+ long-lived server process that did not inherit the client's launch environment.
279
292
  The `opencode` runner remains rooted in its task or epic working directory so
280
293
  the workbase `AGENTS.md` and managed OpenCode config are discovered normally.
281
294
  Agency's managed OpenCode plugin grants the active workbase external-directory
@@ -857,7 +870,8 @@ including readiness checks and durable PR recording. Other `agency pr`
857
870
  invocations forward every argument to `gh pr`. From an execution task or phase
858
871
  directory, including descendants, passthrough runs in that execution unit's
859
872
  authoritative writable checkout. Otherwise it runs in the caller's current
860
- directory.
873
+ directory. In jj workbases, Agency also supplies the repository and the work
874
+ item's declared branch to subcommands that would otherwise infer Git context.
861
875
 
862
876
  ### Status and Validation
863
877
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.54.2",
3
+ "version": "2.54.4",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -1177,7 +1177,7 @@ status: open
1177
1177
  cwd: contract.cwd,
1178
1178
  env: environment,
1179
1179
  })
1180
- expect(probe.exitCode).toBe(0)
1180
+ expect(probe.exitCode, probe.stderr.toString()).toBe(0)
1181
1181
  const agent = JSON.parse(probe.stdout.toString())
1182
1182
  expect(agent.permission).toEqual(
1183
1183
  expect.arrayContaining([
@@ -50,9 +50,19 @@ exit "\${GH_EXIT:-0}"
50
50
  const captured = async () =>
51
51
  (await Bun.file(capturePath).text()).trim().split("\n")
52
52
 
53
- const createExecutionWorkbase = async () => {
53
+ const createExecutionWorkbase = async (vcs: "git" | "jj" = "git") => {
54
54
  const root = join(tempRoot, "workbase")
55
- await write(root, "agency.json", '{"version":2}\n')
55
+ await write(
56
+ root,
57
+ "agency.json",
58
+ `${JSON.stringify({
59
+ version: 2,
60
+ vcs,
61
+ repositories: {
62
+ agency: { remote: "https://github.com/example/agency.git" },
63
+ },
64
+ })}\n`,
65
+ )
56
66
  await mkdir(join(root, "repos/agency"), { recursive: true })
57
67
  await mkdir(join(root, "tasks/single/code/agency"), { recursive: true })
58
68
  await write(
@@ -163,6 +173,51 @@ status: open
163
173
  ])
164
174
  })
165
175
 
176
+ test("injects the execution branch and repository for jj targets", async () => {
177
+ const root = await createExecutionWorkbase("jj")
178
+ const task = join(root, "tasks/single")
179
+ const phase = join(root, "tasks/multi/phases/build")
180
+
181
+ expect(await runTestEffect(pr(["view", "--web"], task))).toBe(0)
182
+ expect(await captured()).toEqual([
183
+ await realpath(join(task, "code/agency")),
184
+ "pr",
185
+ "view",
186
+ "feat/single",
187
+ "--repo",
188
+ "example/agency",
189
+ "--web",
190
+ ])
191
+
192
+ expect(
193
+ await runTestEffect(pr(["create", "--title", "Example"], phase)),
194
+ ).toBe(0)
195
+ expect(await captured()).toEqual([
196
+ await realpath(join(phase, "code/agency")),
197
+ "pr",
198
+ "create",
199
+ "--head",
200
+ "feat/build",
201
+ "--repo",
202
+ "example/agency",
203
+ "--title",
204
+ "Example",
205
+ ])
206
+ })
207
+
208
+ test("preserves explicit jj PR and repository targets", async () => {
209
+ const root = await createExecutionWorkbase("jj")
210
+ const task = join(root, "tasks/single")
211
+ const args = ["view", "123", "--repo", "other/repository"]
212
+
213
+ expect(await runTestEffect(pr(args, task))).toBe(0)
214
+ expect(await captured()).toEqual([
215
+ await realpath(join(task, "code/agency")),
216
+ "pr",
217
+ ...args,
218
+ ])
219
+ })
220
+
166
221
  test("falls back to the invocation directory without execution authority", async () => {
167
222
  const root = await createExecutionWorkbase()
168
223
  const orchestration = join(root, "tasks/multi")
@@ -3,8 +3,10 @@ import { resolve } from "node:path"
3
3
  import { ContextService } from "../services/ContextService"
4
4
  import { FileSystemService } from "../services/FileSystemService"
5
5
  import { PullRequestService } from "../services/PullRequestService"
6
+ import { WorkbaseService } from "../services/WorkbaseService"
6
7
  import type { BaseCommandOptions } from "../utils/command"
7
8
  import { createLoggers } from "../utils/effect"
9
+ import { repositoryFromRemote } from "../workbase/delivery-command"
8
10
 
9
11
  interface PrCreateOptions extends BaseCommandOptions {
10
12
  readonly taskId: string
@@ -13,6 +15,56 @@ interface PrCreateOptions extends BaseCommandOptions {
13
15
  readonly force?: boolean
14
16
  }
15
17
 
18
+ const branchTargetCommands = new Set([
19
+ "checkout",
20
+ "checks",
21
+ "close",
22
+ "comment",
23
+ "diff",
24
+ "edit",
25
+ "lock",
26
+ "merge",
27
+ "ready",
28
+ "reopen",
29
+ "revert",
30
+ "review",
31
+ "unlock",
32
+ "update-branch",
33
+ "view",
34
+ ])
35
+
36
+ const hasOption = (args: readonly string[], short: string, long: string) =>
37
+ args.some(
38
+ (argument) =>
39
+ argument === short ||
40
+ argument.startsWith(`${short}=`) ||
41
+ argument === long ||
42
+ argument.startsWith(`${long}=`),
43
+ )
44
+
45
+ const withJjContext = (
46
+ args: readonly string[],
47
+ branch: string,
48
+ repository: string,
49
+ ) => {
50
+ const [command, ...rest] = args
51
+ if (!command) return args
52
+
53
+ const branchArgs =
54
+ (command === "create" || command === "new") &&
55
+ !hasOption(rest, "-H", "--head")
56
+ ? ["--head", branch]
57
+ : branchTargetCommands.has(command) &&
58
+ (rest.length === 0 || rest[0]?.startsWith("-"))
59
+ ? [branch]
60
+ : []
61
+ const repositoryArgs = hasOption(rest, "-R", "--repo")
62
+ ? []
63
+ : ["--repo", repository]
64
+
65
+ return [command, ...branchArgs, ...repositoryArgs, ...rest]
66
+ }
67
+
16
68
  export const prCreate = (options: PrCreateOptions) =>
17
69
  Effect.gen(function* () {
18
70
  const pullRequests = yield* PullRequestService
@@ -31,6 +83,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
31
83
  Effect.gen(function* () {
32
84
  const contexts = yield* ContextService
33
85
  const fs = yield* FileSystemService
86
+ const workbase = yield* WorkbaseService
34
87
  const invocationCwd = resolve(cwd)
35
88
  const context = yield* contexts
36
89
  .get({ cwd: invocationCwd, target: ".", compact: true })
@@ -40,7 +93,26 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
40
93
  ? context.authority.writable?.checkoutPath
41
94
  : null
42
95
  const focusedCwd = writableCheckout ?? invocationCwd
43
- const result = yield* fs.runCommand(["gh", "pr", ...args], {
96
+ let forwardedArgs = args
97
+ if (
98
+ writableCheckout &&
99
+ context?.workbase.vcs === "jj" &&
100
+ context.authority.writable
101
+ ) {
102
+ const execution =
103
+ context.documents?.phase?.data ?? context.documents?.task?.data
104
+ const { config } = yield* workbase.loadConfig(context.workbase.root)
105
+ const remote =
106
+ config.repositories?.[context.authority.writable.repo]?.remote
107
+ if (execution && "branch" in execution && remote) {
108
+ forwardedArgs = withJjContext(
109
+ args,
110
+ execution.branch,
111
+ repositoryFromRemote(remote),
112
+ )
113
+ }
114
+ }
115
+ const result = yield* fs.runCommand(["gh", "pr", ...forwardedArgs], {
44
116
  cwd: focusedCwd,
45
117
  passthrough: true,
46
118
  })
@@ -53,5 +125,6 @@ Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
53
125
 
54
126
  Create records a pull request for an Agency execution unit. Other invocations
55
127
  run gh pr unchanged, focusing the writable repository checkout when invoked from
56
- an Agency execution task or phase.
128
+ an Agency execution task or phase. In jj workbases, Agency supplies the declared
129
+ branch and repository to gh subcommands that would otherwise infer Git context.
57
130
  `
@@ -98,6 +98,8 @@ const createHarness = (options: HarnessOptions = {}) => {
98
98
  cwd: string
99
99
  }> = []
100
100
  const launchEnvironments: Array<Readonly<Record<string, string>>> = []
101
+ const launchProcessEnvironments: Array<Record<string, string | undefined>> =
102
+ []
101
103
  const materializeOptions: Array<
102
104
  Parameters<WorktreeService["materialize"]>[3]
103
105
  > = []
@@ -265,6 +267,11 @@ const createHarness = (options: HarnessOptions = {}) => {
265
267
  events.push(`launch:${cli}`)
266
268
  launches.push({ cli, args, cwd })
267
269
  launchEnvironments.push(environment)
270
+ launchProcessEnvironments.push({
271
+ AGENCY_SESSION_ID: process.env.AGENCY_SESSION_ID,
272
+ AGENCY_TARGET: process.env.AGENCY_TARGET,
273
+ AGENCY_PROMPT: process.env.AGENCY_PROMPT,
274
+ })
268
275
  if (options.launchError) throw options.launchError
269
276
  }
270
277
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
@@ -307,6 +314,7 @@ const createHarness = (options: HarnessOptions = {}) => {
307
314
  probes,
308
315
  launches,
309
316
  launchEnvironments,
317
+ launchProcessEnvironments,
310
318
  materializeOptions,
311
319
  statusUpdates,
312
320
  taskStatuses,
@@ -484,7 +492,7 @@ describe("work command", () => {
484
492
  args: [
485
493
  "opencode",
486
494
  "--prompt",
487
- "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
495
+ "Agency worker launch target: epic:delivery. Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
488
496
  ],
489
497
  cwd: "/workbase/epics/delivery",
490
498
  })
@@ -538,7 +546,7 @@ describe("work command", () => {
538
546
  args: [
539
547
  "opencode",
540
548
  "--prompt",
541
- "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
549
+ "Agency worker launch target: task:delivery. Work on the task. Read /workbase/tasks/delivery/TASK.md.",
542
550
  ],
543
551
  cwd: "/workbase/tasks/delivery",
544
552
  })
@@ -565,7 +573,7 @@ describe("work command", () => {
565
573
  args: [
566
574
  "opencode",
567
575
  "--prompt",
568
- "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
576
+ "Agency worker launch target: execution-unit:phase/example/implementation. Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
569
577
  ],
570
578
  cwd: phaseDirectory,
571
579
  })
@@ -797,11 +805,17 @@ describe("work command", () => {
797
805
  expect(harness.launches[0]?.args).toEqual([
798
806
  "opencode",
799
807
  "--prompt",
800
- "Start the task. Read /workbase/tasks/example/TASK.md.",
808
+ "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
801
809
  ])
802
810
  expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
803
- "Start the task. Read /workbase/tasks/example/TASK.md.",
811
+ "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
804
812
  )
813
+ expect(harness.launchProcessEnvironments[0]).toEqual({
814
+ AGENCY_SESSION_ID: harness.launchEnvironments[0]?.AGENCY_SESSION_ID,
815
+ AGENCY_TARGET: "execution-unit:task/example",
816
+ AGENCY_PROMPT:
817
+ "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
818
+ })
805
819
  })
806
820
 
807
821
  test("continues existing work with the resume command", async () => {
@@ -813,10 +827,10 @@ describe("work command", () => {
813
827
  "opencode",
814
828
  "--continue",
815
829
  "--prompt",
816
- "Continue the task. Read /workbase/tasks/example/TASK.md.",
830
+ "Agency worker launch target: execution-unit:task/example. Continue the task. Read /workbase/tasks/example/TASK.md.",
817
831
  ])
818
832
  expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
819
- "Continue the task. Read /workbase/tasks/example/TASK.md.",
833
+ "Agency worker launch target: execution-unit:task/example. Continue the task. Read /workbase/tasks/example/TASK.md.",
820
834
  )
821
835
  })
822
836
 
@@ -840,7 +854,7 @@ describe("work command", () => {
840
854
  "opencode",
841
855
  "--continue",
842
856
  "--prompt",
843
- "Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
857
+ "Agency worker launch target: execution-unit:phase/example/implementation. Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
844
858
  ],
845
859
  cwd: phaseDirectory,
846
860
  })
@@ -870,7 +884,7 @@ describe("work command", () => {
870
884
  "codex",
871
885
  "--task",
872
886
  "example",
873
- "Start the task. Read /workbase/tasks/example/TASK.md.",
887
+ "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
874
888
  ],
875
889
  cwd: taskDirectory,
876
890
  })
@@ -264,6 +264,7 @@ export const work = (
264
264
  launchPath = dirname(workspace.phasePath ?? workspace.taskPath)
265
265
  writablePath = workspace.writablePath ?? undefined
266
266
  }
267
+ prompt = `Agency worker launch target: ${targetNodeId(target)}. ${prompt}`
267
268
 
268
269
  const explicitlyRequested = Boolean(
269
270
  options.runner ||
@@ -129,6 +129,36 @@ describe("IntegrationService", () => {
129
129
  expect(managedWorkbaseOpencodePlugin).toContain(
130
130
  "config.skills.paths = [...new Set",
131
131
  )
132
+ expect(managedWorkbaseOpencodePlugin).toContain('"chat.message"')
133
+ expect(managedWorkbaseOpencodePlugin).toContain(
134
+ "if (!Array.isArray(parts)) return",
135
+ )
136
+ expect(managedWorkbaseOpencodePlugin).toContain(
137
+ '"experimental.chat.system.transform"',
138
+ )
139
+ expect(managedWorkbaseOpencodePlugin).toContain('"shell.env"')
140
+ expect(managedWorkbaseOpencodePlugin).toContain(
141
+ "if (context?.target !== launchTarget) return",
142
+ )
143
+ expect(managedWorkbaseOpencodePlugin).toContain(
144
+ "agencyContext(directory, false)",
145
+ )
146
+ expect(managedWorkbaseOpencodePlugin).toContain(
147
+ "result.validation?.valid !== true",
148
+ )
149
+ expect(managedWorkbaseOpencodePlugin).toContain(
150
+ "dirname(document) !== resolve(directory)",
151
+ )
152
+ expect(managedWorkbaseOpencodePlugin).toContain(
153
+ "!result.authority?.writable?.checkoutPath",
154
+ )
155
+ expect(managedWorkbaseOpencodePlugin).toContain('status !== "working"')
156
+ expect(managedWorkbaseOpencodePlugin).toContain(
157
+ "output.env.AGENCY_SESSION_ID = sessionID",
158
+ )
159
+ expect(managedWorkbaseOpencodePlugin).toContain(
160
+ "Do not invoke agency work for this target",
161
+ )
132
162
  expect(managedWorkbaseOpencodePlugin).toContain(
133
163
  ".map((path) => `${path}${sep}.`)",
134
164
  )
@@ -142,6 +172,121 @@ describe("IntegrationService", () => {
142
172
  ).toBe(false)
143
173
  })
144
174
 
175
+ test("binds validated worker identity to an OpenCode session", async () => {
176
+ const path = join(root, "agency-repository-skills.ts")
177
+ const contextResponse = JSON.stringify({
178
+ version: 1,
179
+ ok: true,
180
+ result: {
181
+ workbase: { root },
182
+ target: {
183
+ kind: "task",
184
+ taskId: "example",
185
+ path: join(root, "TASK.md"),
186
+ },
187
+ authority: {
188
+ mode: "execution",
189
+ writable: { checkoutPath: join(root, "tasks/example/code/agency") },
190
+ },
191
+ documents: { task: { data: { status: "working" } } },
192
+ validation: { valid: true, warnings: [] },
193
+ },
194
+ })
195
+ await Bun.write(
196
+ path,
197
+ managedWorkbaseOpencodePlugin
198
+ .replace("const contextTarget =", "export const contextTarget =")
199
+ .replace("const agencyContext =", "export const agencyContext =")
200
+ .replace(
201
+ "const workerLaunchTarget =",
202
+ "export const workerLaunchTarget =",
203
+ ),
204
+ )
205
+ const originalSpawn = Bun.spawn
206
+ Bun.spawn = (() =>
207
+ ({
208
+ stdout: new Response(contextResponse).body!,
209
+ exited: Promise.resolve(0),
210
+ }) as ReturnType<typeof Bun.spawn>) as typeof Bun.spawn
211
+ try {
212
+ const generated = await import(
213
+ `${pathToFileURL(path).href}?worker-identity`
214
+ )
215
+ expect(
216
+ generated.workerLaunchTarget([
217
+ {
218
+ type: "text",
219
+ text: "Agency worker launch target: execution-unit:task/example. Start the task.",
220
+ },
221
+ ]),
222
+ ).toBe("execution-unit:task/example")
223
+ expect(generated.workerLaunchTarget(undefined)).toBeUndefined()
224
+ expect(
225
+ generated.contextTarget({
226
+ target: { kind: "task", taskId: "example" },
227
+ authority: { mode: "execution" },
228
+ }),
229
+ ).toBe("execution-unit:task/example")
230
+ expect(await generated.agencyContext(root)).toMatchObject({
231
+ root,
232
+ target: "execution-unit:task/example",
233
+ task: "example",
234
+ })
235
+ const hooks = await generated.default({ directory: root } as never)
236
+ await hooks["chat.message"]!(
237
+ { sessionID: "worker-session" } as never,
238
+ {
239
+ parts: [
240
+ {
241
+ type: "text",
242
+ text: "Agency worker launch target: execution-unit:task/example. Start the task.",
243
+ },
244
+ ],
245
+ } as never,
246
+ )
247
+ await hooks["chat.message"]!(
248
+ { sessionID: "mismatched-session" } as never,
249
+ {
250
+ parts: [
251
+ {
252
+ type: "text",
253
+ text: "Agency worker launch target: execution-unit:task/other. Start the task.",
254
+ },
255
+ ],
256
+ } as never,
257
+ )
258
+
259
+ const system = { system: [] as string[] }
260
+ await hooks["experimental.chat.system.transform"]!(
261
+ { sessionID: "worker-session" } as never,
262
+ system,
263
+ )
264
+ expect(system.system).toEqual([
265
+ expect.stringContaining(
266
+ "active worker for execution-unit:task/example",
267
+ ),
268
+ ])
269
+ const mismatchedSystem = { system: [] as string[] }
270
+ await hooks["experimental.chat.system.transform"]!(
271
+ { sessionID: "mismatched-session" } as never,
272
+ mismatchedSystem,
273
+ )
274
+ expect(mismatchedSystem.system).toEqual([])
275
+
276
+ const shell = { env: {} as Record<string, string> }
277
+ await hooks["shell.env"]!({ sessionID: "worker-session" } as never, shell)
278
+ expect(shell.env).toMatchObject({
279
+ AGENCY_SESSION_ID: "worker-session",
280
+ AGENCY_TARGET: "execution-unit:task/example",
281
+ AGENCY_WORKBASE: root,
282
+ AGENCY_TASK_ID: "example",
283
+ AGENCY_WRITABLE_CHECKOUT: join(root, "tasks/example/code/agency"),
284
+ })
285
+ } finally {
286
+ Bun.spawn = originalSpawn
287
+ }
288
+ })
289
+
145
290
  test("registers a TUI-only /agency-debug diagnostic", async () => {
146
291
  const config = JSON.parse(managedBody(managedWorkbaseOpencodeTui))
147
292
  expect(config).toEqual({
@@ -268,6 +413,11 @@ describe("IntegrationService", () => {
268
413
  expect(body).toContain("Preserve parent backlinks")
269
414
  expect(body).toContain("dirty-worktree, active-claim, revision")
270
415
  expect(body).toContain("`agency work` is the human launch flow")
416
+ expect(body).toContain("Agency worker launch target: <target>.")
417
+ expect(body).toContain("environment variables and a generated")
418
+ expect(body).toContain("the initial instruction is a generated")
419
+ expect(body).toContain("Herdr state is never part of worker identity")
420
+ expect(body).toMatch(/If the prompt\s+and context disagree/)
271
421
  expect(body).toContain("marks execution work")
272
422
  expect(body).toContain("without creating a claim")
273
423
  expect(body).toContain("formatting, type checks, build, dead-code checks")
@@ -16,20 +16,15 @@ import {
16
16
  normalizePullRequestRecord,
17
17
  parsePullRequestRecord,
18
18
  recordFromGitHubUrl,
19
+ repositoryFromRemote,
19
20
  resolveDeliveryCommand,
21
+ resolveGitHubCreateCommand,
20
22
  } from "../workbase/delivery-command"
21
23
 
22
24
  class PullRequestError extends Data.TaggedError("PullRequestError")<{
23
25
  readonly message: string
24
26
  }> {}
25
27
 
26
- const repositoryFromRemote = (remote: string) =>
27
- remote
28
- .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
29
- .replace(/^[^:]+:/, "")
30
- .replace(/\.git\/?$/, "")
31
- .replace(/\/$/, "")
32
-
33
28
  interface PullRequestOptions extends BaseCommandOptions {
34
29
  readonly force?: boolean
35
30
  }
@@ -197,18 +192,13 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
197
192
  url: "",
198
193
  identifier: "",
199
194
  })
200
- : {
201
- argv: [
202
- "gh",
203
- "pr",
204
- "create",
205
- "--fill",
206
- "--base",
207
- execution.base,
208
- ...(draft ? ["--draft"] : []),
209
- ],
210
- environment: {},
211
- }
195
+ : resolveGitHubCreateCommand({
196
+ base: execution.base,
197
+ branch: execution.branch,
198
+ repository,
199
+ draft,
200
+ vcs: config.vcs ?? "git",
201
+ })
212
202
  const created = yield* fs.runCommand(resolved.argv, {
213
203
  cwd: workspace.writablePath,
214
204
  captureOutput: true,
@@ -76,6 +76,28 @@ task launches remain orchestration-only. External orchestrators instead claim
76
76
  an execution unit, launch and monitor their runner separately, and finish or
77
77
  release the claim with the current document revision.
78
78
 
79
+ An Agency-launched runner receives process-local worker identity through both
80
+ the `AGENCY_SESSION_ID` and `AGENCY_TARGET` environment variables and a generated
81
+ prompt beginning `Agency worker launch target: <target>.` Treat either form as
82
+ launch evidence only after `agency context . --json` confirms the same target,
83
+ document paths, valid context, and expected write authority. Once confirmed,
84
+ perform the assigned work directly and never invoke `agency work` to start the
85
+ same target again.
86
+
87
+ Some runner clients attach to a long-lived process and may not preserve launch
88
+ environment variables. If the variables and prompt marker are absent, fail safe
89
+ when the initial instruction is a generated `Start`, `Continue`, or `Work on`
90
+ prompt whose absolute document paths match the current directory and the active,
91
+ valid `agency context`: treat the process as the current worker and do not
92
+ recursively launch. Herdr state is never part of worker identity. If the prompt
93
+ and context disagree, stop and ask the user rather than launching.
94
+
95
+ For OpenCode, Agency's managed plugin validates the generated marker against
96
+ `agency context`, binds that identity to the OpenCode session, injects an
97
+ active-worker system instruction, and supplies Agency identity to that session's
98
+ shell environment. This avoids relying on the environment of OpenCode's
99
+ long-lived server process.
100
+
79
101
  ## Closeout
80
102
 
81
103
  An execution unit remains `working` after implementation is committed and while
@@ -3,6 +3,7 @@ import {
3
3
  parsePullRequestRecord,
4
4
  recordFromGitHubJson,
5
5
  resolveDeliveryCommand,
6
+ resolveGitHubCreateCommand,
6
7
  validateDelivery,
7
8
  } from "./delivery-command"
8
9
 
@@ -31,6 +32,34 @@ describe("delivery commands", () => {
31
32
  })
32
33
  })
33
34
 
35
+ test("adds explicit jj context to the default GitHub create command", () => {
36
+ const input = {
37
+ base: "main",
38
+ branch: "feat/example",
39
+ repository: "example/agency",
40
+ draft: false,
41
+ } as const
42
+ expect(resolveGitHubCreateCommand({ ...input, vcs: "jj" })).toEqual({
43
+ argv: [
44
+ "gh",
45
+ "pr",
46
+ "create",
47
+ "--fill",
48
+ "--base",
49
+ "main",
50
+ "--head",
51
+ "feat/example",
52
+ "--repo",
53
+ "example/agency",
54
+ ],
55
+ environment: {},
56
+ })
57
+ expect(resolveGitHubCreateCommand({ ...input, vcs: "git" })).toEqual({
58
+ argv: ["gh", "pr", "create", "--fill", "--base", "main"],
59
+ environment: {},
60
+ })
61
+ })
62
+
34
63
  test("rejects unknown placeholders", () => {
35
64
  expect(() =>
36
65
  validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
@@ -11,6 +11,39 @@ export interface DeliveryCommandVariables {
11
11
  readonly identifier: string
12
12
  }
13
13
 
14
+ export const repositoryFromRemote = (remote: string) =>
15
+ remote
16
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
17
+ .replace(/^[^:]+:/, "")
18
+ .replace(/\.git\/?$/, "")
19
+ .replace(/\/$/, "")
20
+
21
+ export const resolveGitHubCreateCommand = ({
22
+ base,
23
+ branch,
24
+ repository,
25
+ draft,
26
+ vcs,
27
+ }: {
28
+ readonly base: string
29
+ readonly branch: string
30
+ readonly repository: string
31
+ readonly draft: boolean
32
+ readonly vcs: "git" | "jj"
33
+ }) => ({
34
+ argv: [
35
+ "gh",
36
+ "pr",
37
+ "create",
38
+ "--fill",
39
+ "--base",
40
+ base,
41
+ ...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),
42
+ ...(draft ? ["--draft"] : []),
43
+ ],
44
+ environment: {},
45
+ })
46
+
14
47
  const PLACEHOLDERS = new Set<keyof DeliveryCommandVariables>([
15
48
  "repository",
16
49
  "branch",
@@ -7,9 +7,41 @@ const checksum = (content: string) =>
7
7
  createHash("sha256").update(content).digest("hex")
8
8
 
9
9
  const body = `import { existsSync, readFileSync } from "node:fs"
10
- import { dirname, join, sep } from "node:path"
10
+ import { dirname, join, resolve, sep } from "node:path"
11
11
  import type { Plugin } from "@opencode-ai/plugin"
12
12
 
13
+ const workerLaunchPattern = /^Agency worker launch target: ([^.\\s]+)\\./
14
+
15
+ type AgencyContext = {
16
+ root?: string
17
+ checkout?: string
18
+ target?: string
19
+ task?: string
20
+ phase?: string
21
+ }
22
+
23
+ const contextTarget = (result: Record<string, any>): string | undefined => {
24
+ const target = result.target
25
+ if (target?.kind === "epic") return \`epic:\${target.epicId}\`
26
+ if (target?.kind === "phase") {
27
+ return \`execution-unit:phase/\${target.taskId}/\${target.phaseId}\`
28
+ }
29
+ if (target?.kind === "task") {
30
+ return result.authority?.mode === "execution"
31
+ ? \`execution-unit:task/\${target.taskId}\`
32
+ : \`task:\${target.taskId}\`
33
+ }
34
+ }
35
+
36
+ const workerLaunchTarget = (parts: unknown) => {
37
+ if (!Array.isArray(parts)) return
38
+ const text = parts
39
+ .filter((part) => part.type === "text")
40
+ .map((part) => part.text ?? "")
41
+ .join("\\n")
42
+ return text.match(workerLaunchPattern)?.[1]
43
+ }
44
+
13
45
  const discoverWorkbase = (directory: string) => {
14
46
  let current = directory
15
47
  while (true) {
@@ -37,64 +69,117 @@ const discoverCheckout = (directory: string, root: string | undefined) => {
37
69
  }
38
70
  }
39
71
 
40
- const agencyContext = async (directory: string) => {
41
- const task = process.env.AGENCY_TASK_ID
42
- const phase = process.env.AGENCY_PHASE_ID
72
+ const agencyContext = async (
73
+ directory: string,
74
+ useEnvironmentTarget = true,
75
+ ): Promise<AgencyContext | undefined> => {
76
+ const task = useEnvironmentTarget ? process.env.AGENCY_TASK_ID : undefined
77
+ const phase = useEnvironmentTarget ? process.env.AGENCY_PHASE_ID : undefined
43
78
  const args = task
44
79
  ? ["agency", "context", "--task", task, ...(phase ? ["--phase", phase] : []), "--compact", "--json"]
45
80
  : ["agency", "context", ".", "--compact", "--json"]
46
- const child = Bun.spawn(args, { cwd: directory, stdout: "pipe", stderr: "ignore" })
81
+ const child = Bun.spawn(args, {
82
+ cwd: directory,
83
+ env: process.env,
84
+ stdout: "pipe",
85
+ stderr: "ignore",
86
+ })
47
87
  const output = await new Response(child.stdout).text()
48
88
  if ((await child.exited) !== 0) return
49
89
  const envelope = JSON.parse(output)
50
90
  if (envelope.ok !== true) return
91
+ const result = envelope.result ?? {}
92
+ const target = contextTarget(result)
93
+ const document = result.target?.path
94
+ const status = result.documents?.phase?.data?.status ?? result.documents?.task?.data?.status
95
+ if (
96
+ result.validation?.valid !== true ||
97
+ !target ||
98
+ !document ||
99
+ dirname(document) !== resolve(directory) ||
100
+ (target.startsWith("execution-unit:") &&
101
+ (!result.authority?.writable?.checkoutPath || status !== "working"))
102
+ ) return
51
103
  return {
52
- root: envelope.result?.workbase?.root as string | undefined,
53
- checkout: envelope.result?.authority?.writable?.checkoutPath as string | undefined,
104
+ root: result.workbase?.root,
105
+ checkout: result.authority?.writable?.checkoutPath,
106
+ target,
107
+ task: result.target?.taskId,
108
+ phase: result.target?.phaseId,
54
109
  }
55
110
  }
56
111
 
57
- const plugin: Plugin = async ({ directory }) => ({
58
- config: async (config) => {
59
- const context = await agencyContext(directory).catch(() => undefined)
60
- const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
61
- const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
62
-
63
- const reference = config.references?.workbase
64
- if (
65
- root &&
66
- typeof reference === "object" &&
67
- reference.path === ".." &&
68
- reference.description ===
69
- "Complete Agency workbase context; write authority still comes only from agency context" &&
70
- typeof config.permission !== "string"
71
- ) {
72
- config.permission ??= {}
73
- const external = config.permission.external_directory
74
- if (external === undefined) {
75
- config.permission.external_directory = { [join(root, "*")]: "allow" }
76
- } else if (typeof external === "object") {
77
- config.permission.external_directory = {
78
- [join(root, "*")]: "allow",
79
- ...external,
112
+ const plugin: Plugin = async ({ directory }) => {
113
+ const workerSessions = new Map<string, AgencyContext>()
114
+
115
+ return {
116
+ config: async (config) => {
117
+ const context = await agencyContext(directory).catch(() => undefined)
118
+ const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
119
+ const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
120
+
121
+ const reference = config.references?.workbase
122
+ if (
123
+ root &&
124
+ typeof reference === "object" &&
125
+ reference.path === ".." &&
126
+ reference.description ===
127
+ "Complete Agency workbase context; write authority still comes only from agency context" &&
128
+ typeof config.permission !== "string"
129
+ ) {
130
+ config.permission ??= {}
131
+ const external = config.permission.external_directory
132
+ if (external === undefined) {
133
+ config.permission.external_directory = { [join(root, "*")]: "allow" }
134
+ } else if (typeof external === "object") {
135
+ config.permission.external_directory = {
136
+ [join(root, "*")]: "allow",
137
+ ...external,
138
+ }
80
139
  }
81
140
  }
82
- }
83
141
 
84
- if (!checkout) return
85
-
86
- const paths = [
87
- join(checkout, ".claude", "skills"),
88
- join(checkout, ".agents", "skills"),
89
- join(checkout, ".opencode", "skill"),
90
- join(checkout, ".opencode", "skills"),
91
- ].filter(existsSync).map((path) => \`\${path}\${sep}.\`)
92
- if (paths.length === 0) return
93
-
94
- config.skills ??= {}
95
- config.skills.paths = [...new Set([...(config.skills.paths ?? []), ...paths])]
96
- },
97
- })
142
+ if (!checkout) return
143
+
144
+ const paths = [
145
+ join(checkout, ".claude", "skills"),
146
+ join(checkout, ".agents", "skills"),
147
+ join(checkout, ".opencode", "skill"),
148
+ join(checkout, ".opencode", "skills"),
149
+ ].filter(existsSync).map((path) => \`\${path}\${sep}.\`)
150
+ if (paths.length === 0) return
151
+
152
+ config.skills ??= {}
153
+ config.skills.paths = [...new Set([...(config.skills.paths ?? []), ...paths])]
154
+ },
155
+ "chat.message": async ({ sessionID }, output) => {
156
+ const launchTarget = workerLaunchTarget(output.parts)
157
+ if (!launchTarget) return
158
+ const context = await agencyContext(directory, false).catch(() => undefined)
159
+ if (context?.target !== launchTarget) return
160
+ workerSessions.set(sessionID, context)
161
+ },
162
+ "experimental.chat.system.transform": async ({ sessionID }, output) => {
163
+ if (!sessionID) return
164
+ const context = workerSessions.get(sessionID)
165
+ if (!context?.target) return
166
+ output.system.push(
167
+ \`Agency verified this OpenCode session as the active worker for \${context.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
168
+ )
169
+ },
170
+ "shell.env": async ({ sessionID }, output) => {
171
+ if (!sessionID) return
172
+ const context = workerSessions.get(sessionID)
173
+ if (!context?.target) return
174
+ output.env.AGENCY_SESSION_ID = sessionID
175
+ output.env.AGENCY_TARGET = context.target
176
+ if (context.root) output.env.AGENCY_WORKBASE = context.root
177
+ if (context.checkout) output.env.AGENCY_WRITABLE_CHECKOUT = context.checkout
178
+ if (context.task) output.env.AGENCY_TASK_ID = context.task
179
+ if (context.phase) output.env.AGENCY_PHASE_ID = context.phase
180
+ },
181
+ }
182
+ }
98
183
 
99
184
  export default plugin
100
185
  `
@@ -95,9 +95,12 @@ describe("runner commands", () => {
95
95
  expect(environment).toMatchObject({
96
96
  AGENCY_RUNNER: "custom",
97
97
  AGENCY_CLAIMANT: "orchestrator",
98
+ AGENCY_SESSION_ID: "session-1",
99
+ AGENCY_WORKBASE: "/workbase",
98
100
  AGENCY_TARGET: "execution-unit:phase/task/build",
99
101
  AGENCY_TASK_ID: "task",
100
102
  AGENCY_PHASE_ID: "build",
103
+ AGENCY_PROMPT: "Read the task.",
101
104
  })
102
105
  expect(printableEnvironment(environment).VISIBLE).toBe("yes")
103
106
  expect(printableEnvironment(environment).ACCESS_TOKEN).toBeUndefined()