@markjaquith/agency 2.54.3 → 2.55.0

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
@@ -799,12 +812,13 @@ restore preflight all destinations and graph references before changing files.
799
812
  Versioned lifecycle provenance preserves parent declarations and dependency edges
800
813
  for restoration. Archived IDs are reserved until restored.
801
814
 
802
- ### Work and Pull Requests
815
+ ### Work, Publication, and Pull Requests
803
816
 
804
817
  ```text
805
818
  agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
806
819
  agency work prepare [target] [--dry-run] [--json]
807
820
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
821
+ agency push [--json]
808
822
  agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
809
823
  agency pr [args...]
810
824
  ```
@@ -852,6 +866,22 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
852
866
  reference worktrees are reused only while their commit still matches the declared
853
867
  ref; use a commit SHA as `ref` when reproducibility matters.
854
868
 
869
+ `agency push` publishes the execution unit identified by current Agency context
870
+ without creating a pull request. It requires a valid, registered writable
871
+ checkout in `working` state, fetches the configured delivery remote, verifies
872
+ that the declared base is in the publication history, validates every outgoing
873
+ commit's description, author, and conflict state, and refuses non-fast-forward
874
+ updates.
875
+
876
+ For Git, YAML `branch` must exactly match the checked-out local branch, the
877
+ worktree must be clean, and `HEAD` is pushed with upstream tracking. For jj, YAML
878
+ `branch` is the authoritative delivery bookmark and need not exist before
879
+ publication. Agency publishes `@` unless it is the canonical empty, undescribed
880
+ post-commit working copy, in which case it publishes `@-`; described empty
881
+ changes remain intentional publication tips. Missing descriptions or authors
882
+ stop with exact change IDs and remediation commands. Agency creates or safely
883
+ advances only the declared bookmark and never invents a `push-*` bookmark.
884
+
855
885
  Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
856
886
  including readiness checks and durable PR recording. Other `agency pr`
857
887
  invocations forward every argument to `gh pr`. From an execution task or phase
package/cli-main.ts CHANGED
@@ -6,6 +6,7 @@ import { parseCli } from "./src/cli-parser"
6
6
  import { init, help as initHelp } from "./src/commands/init"
7
7
  import { task, help as taskHelp } from "./src/commands/task"
8
8
  import { pr, prCreate, help as prHelp } from "./src/commands/pr"
9
+ import { push, help as pushHelp } from "./src/commands/push"
9
10
  import { work, workPrepare, help as workHelp } from "./src/commands/work"
10
11
  import { worktree, help as worktreeHelp } from "./src/commands/worktree"
11
12
  import { status, help as statusHelp } from "./src/commands/status"
@@ -34,6 +35,7 @@ import { TaskService } from "./src/services/TaskService"
34
35
  import { PhaseService } from "./src/services/PhaseService"
35
36
  import { WorktreeService } from "./src/services/WorktreeService"
36
37
  import { PullRequestService } from "./src/services/PullRequestService"
38
+ import { PushService } from "./src/services/PushService"
37
39
  import { ArchiveService } from "./src/services/ArchiveService"
38
40
  import { IntegrationService } from "./src/services/IntegrationService"
39
41
  import { ContextService } from "./src/services/ContextService"
@@ -79,6 +81,7 @@ const CliLayer = Layer.mergeAll(
79
81
  PhaseService.Default,
80
82
  WorktreeService.Default,
81
83
  PullRequestService.Default,
84
+ PushService.Default,
82
85
  ArchiveService.Default,
83
86
  IntegrationService.Default,
84
87
  ContextService.Default,
@@ -305,6 +308,22 @@ const commands: Record<string, Command> = {
305
308
  )
306
309
  },
307
310
  },
311
+ push: {
312
+ run: async (_args: string[], options: Record<string, any>) => {
313
+ if (options.help) {
314
+ console.log(pushHelp)
315
+ return
316
+ }
317
+ await runCommand(
318
+ push({
319
+ json: options.json,
320
+ silent: options.silent,
321
+ verbose: options.verbose,
322
+ cwd: options.cwd,
323
+ }),
324
+ )
325
+ },
326
+ },
308
327
  phase: {
309
328
  run: async (args: string[], options: Record<string, any>) => {
310
329
  if (options.help) return console.log(phaseHelp)
@@ -742,6 +761,7 @@ Commands:
742
761
  vcs <subcommand> Inspect or migrate the version-control backend
743
762
  next List or select ready execution units
744
763
  pr create / pr [...] Create an Agency PR or run gh pr with repository focus
764
+ push Validate and publish the current execution unit
745
765
  review refresh Explicitly refresh a pinned review task
746
766
  repo <subcommand> Manage workbase repositories
747
767
  status Show status for the current workbase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.54.3",
3
+ "version": "2.55.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -616,6 +616,15 @@ describe("strict CLI parsing", () => {
616
616
  ).toThrow("cannot be combined")
617
617
  })
618
618
 
619
+ test("parses push without accepting an alternate target", () => {
620
+ expect(parseCli(["push", "--json"])).toMatchObject({
621
+ commandName: "push",
622
+ args: [],
623
+ values: { json: true },
624
+ })
625
+ expectUsageError(["push", "example"], "agency push")
626
+ })
627
+
619
628
  test("accepts runner selection and command inspection for work", () => {
620
629
  expect(
621
630
  parseCli([
package/src/cli-parser.ts CHANGED
@@ -913,6 +913,16 @@ const commands = {
913
913
  },
914
914
  },
915
915
  },
916
+ push: {
917
+ usage: "agency push [--json]",
918
+ options: outputOptions,
919
+ command: {
920
+ usage: "agency push [--json]",
921
+ minArgs: 0,
922
+ maxArgs: 0,
923
+ options: ["json"],
924
+ },
925
+ },
916
926
  next: {
917
927
  usage: "agency next [--select] [--json]",
918
928
  options: {
package/src/cli.test.ts CHANGED
@@ -502,6 +502,7 @@ describe("CLI", () => {
502
502
  ["archive", "Usage: agency archive"],
503
503
  ["restore", "Usage: agency restore"],
504
504
  ["work", "Usage: agency work"],
505
+ ["push", "Usage: agency push"],
505
506
  ["pr", "Work with GitHub pull requests."],
506
507
  ["status", "Usage: agency status"],
507
508
  ["validate", "Usage: agency validate"],
@@ -1177,7 +1178,7 @@ status: open
1177
1178
  cwd: contract.cwd,
1178
1179
  env: environment,
1179
1180
  })
1180
- expect(probe.exitCode).toBe(0)
1181
+ expect(probe.exitCode, probe.stderr.toString()).toBe(0)
1181
1182
  const agent = JSON.parse(probe.stdout.toString())
1182
1183
  expect(agent.permission).toEqual(
1183
1184
  expect.arrayContaining([
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect"
2
+ import { PushService } from "../services/PushService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ export const push = (options: BaseCommandOptions = {}) =>
7
+ Effect.gen(function* () {
8
+ const publications = yield* PushService
9
+ const { log } = createLoggers(options)
10
+ const result = yield* publications.publish(options.cwd ?? process.cwd())
11
+ log(
12
+ options.json
13
+ ? JSON.stringify(result, null, 2)
14
+ : `Published ${result.vcs} ${result.branch} at ${result.tip} to ${result.remote}`,
15
+ )
16
+ })
17
+
18
+ export const help = `
19
+ Usage: agency push [--json]
20
+
21
+ Validate and publish the current Agency execution unit without creating a pull
22
+ request. The command uses the durable base and branch declarations, requires the
23
+ managed writable checkout and working status, refreshes remote state, validates
24
+ every outgoing commit, and rejects non-fast-forward publication.
25
+ `
@@ -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 ||
package/src/protocol.ts CHANGED
@@ -99,6 +99,7 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
99
99
  },
100
100
  ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
101
101
  WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
102
+ PushError: { code: "PUSH_ERROR", retryable: false },
102
103
  PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
103
104
  ReviewError: { code: "REVIEW_ERROR", retryable: false },
104
105
  ContextError: {
@@ -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,10 +413,17 @@ 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")
274
424
  expect(body).toContain("Review and commit the diff")
425
+ expect(body).toContain("Use `agency push`")
426
+ expect(body).toContain("never authors semantic commit descriptions")
275
427
  expect(body).toContain("Run `agency validate`")
276
428
  expect(body).toContain("only with explicit user intent")
277
429
  expect(body).toContain("An execution unit remains `working`")