@markjaquith/agency 2.39.1 → 2.40.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
@@ -427,7 +427,7 @@ materializing or pushing. Blocked, done, and dropped targets are rejected unless
427
427
  `agency sync` first compares portable repository declarations with local
428
428
  materializations, then compares every execution declaration with local branch
429
429
  and worktree registration, checkout dirtiness, resolved reference commits, claim
430
- expiry, and pull request and merge state. It reports
430
+ expiry, and pull request state, merge state, and mergeability. It reports
431
431
  structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
432
432
  default and `--dry-run` modes are observational.
433
433
 
@@ -437,7 +437,7 @@ default and `--dry-run` modes are observational.
437
437
  - adopt legacy materializations only when they have an unambiguous portable origin;
438
438
  - materialize missing checkouts when no registration, branch, or path conflicts;
439
439
  - release an active claim only after its declared expiry has passed;
440
- - record a single PR whose head and base match the declaration; and
440
+ - record or refresh a single PR whose head and base match the declaration; and
441
441
  - mark work done after its authoritative PR is merged and no active claim remains.
442
442
 
443
443
  Apply never overwrites linked or invalid repositories, repairs remote drift,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.39.1",
3
+ "version": "2.40.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -126,7 +126,9 @@ status: open
126
126
  `ticketUrl` belongs to tasks and epics, not phases. `description` is optional but
127
127
  must be non-empty when present. `pr` is `null`, a legacy GitHub PR URL, or a
128
128
  provider-neutral record containing `provider`, `repository`, `identifier`, `url`,
129
- `state`, `draft`, and `merged`. New PR creation writes the structured record.
129
+ `state`, `draft`, `merged`, and optional `mergeable`. Mergeability is `true`,
130
+ `false`, or `null` when the provider cannot determine it. New PR creation writes
131
+ the structured record.
130
132
  Status is `open`, `working`, `delegated`, `done`, or `dropped`; `delegated` is
131
133
  readable legacy state but cannot be newly assigned.
132
134
 
@@ -239,8 +239,9 @@ agency worktree repair <task-id> [phase-id] --dry-run --json
239
239
 
240
240
  Use `agency sync --apply` only with explicit user intent. It may safely
241
241
  materialize an unconflicted missing checkout, release an expired claim, record a
242
- single matching PR, or mark work done after its authoritative PR merged and no
243
- claim remains. It never modifies dirty checkouts, switches branches, resets
242
+ single matching PR, refresh recorded PR state and mergeability, or mark work done
243
+ after its authoritative PR merged and no claim remains. It never modifies dirty
244
+ checkouts, switches branches, resets
244
245
  references, chooses among PRs, or bypasses active claims.
245
246
 
246
247
  For a worktree-specific issue, use `worktree repair --dry-run` before repair.
package/src/cli.test.ts CHANGED
@@ -1019,7 +1019,13 @@ status: open
1019
1019
  const contract = JSON.parse(printed.stdout)
1020
1020
  expect(contract.cwd).toBe(launch.cwd)
1021
1021
  expect(contract.environment.OPENCODE_CONFIG).toBeUndefined()
1022
- expect(contract.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
1022
+ expect(
1023
+ JSON.parse(contract.environment.OPENCODE_CONFIG_CONTENT),
1024
+ ).toEqual({
1025
+ permission: {
1026
+ external_directory: { [join(workbaseRoot, "*")]: "allow" },
1027
+ },
1028
+ })
1023
1029
  const environment = {
1024
1030
  ...process.env,
1025
1031
  ...contract.environment,
@@ -45,6 +45,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
45
45
  }
46
46
 
47
47
  const taskDirectory = "/workbase/tasks/example"
48
+ const workbasePermission = JSON.stringify({
49
+ permission: { external_directory: { "/workbase/*": "allow" } },
50
+ })
48
51
 
49
52
  interface HarnessOptions {
50
53
  readonly workspace?: ExecutionWorkspace
@@ -487,9 +490,9 @@ describe("work command", () => {
487
490
  cwd: "/workbase/epics/delivery",
488
491
  })
489
492
  expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
490
- expect(
491
- harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
492
- ).toBeUndefined()
493
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT).toBe(
494
+ workbasePermission,
495
+ )
493
496
  })
494
497
 
495
498
  test("resolves an existing positional path before treating it as a task ID", async () => {
@@ -915,7 +918,7 @@ describe("work command", () => {
915
918
  expect(harness.taskStatuses.example).toBe("done")
916
919
  })
917
920
 
918
- test("does not inject runtime OpenCode configuration", async () => {
921
+ test("grants managed OpenCode launches absolute workbase access", async () => {
919
922
  const harness = createHarness()
920
923
  const output = await captureLogs(() =>
921
924
  harness.run({ taskId: "example", opencode: true, printCommand: true }),
@@ -923,7 +926,7 @@ describe("work command", () => {
923
926
  const printed = JSON.parse(output.join("\n"))
924
927
 
925
928
  expect(printed.environment.OPENCODE_CONFIG).toBeUndefined()
926
- expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
929
+ expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBe(workbasePermission)
927
930
  })
928
931
 
929
932
  test("does not override customized OpenCode access policy", async () => {
@@ -122,7 +122,10 @@ export const work = (
122
122
  const inputAllowed = options.inputAllowed ?? true
123
123
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
124
124
  if (!root) return
125
- yield* integrations.sync(root)
125
+ const integration = yield* integrations.sync(root)
126
+ const managedOpencode = integration.files.some(
127
+ (file) => file.name === "opencode" && file.state === "managed",
128
+ )
126
129
  const { config } = yield* workbase.loadConfig(root)
127
130
 
128
131
  let target: WorkTarget | null = null
@@ -331,6 +334,13 @@ export const work = (
331
334
  ...resolved.environment,
332
335
  ...runnerEnvironment(runner, variables),
333
336
  }
337
+ if (runner === "opencode" && managedOpencode) {
338
+ environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
339
+ permission: {
340
+ external_directory: { [join(root, "*")]: "allow" },
341
+ },
342
+ })
343
+ }
334
344
  if (options.printCommand) {
335
345
  log(
336
346
  JSON.stringify(
@@ -46,14 +46,18 @@ describe("SyncService", () => {
46
46
  await Bun.write(
47
47
  gh,
48
48
  `#!/bin/sh
49
+ case "$*" in
50
+ *mergeable*) ;;
51
+ *) echo "mergeable field was not requested" >&2; exit 2 ;;
52
+ esac
49
53
  if [ "$2" = "view" ]; then
50
54
  cat <<'JSON'
51
- {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}
55
+ {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
52
56
  JSON
53
57
  exit 0
54
58
  fi
55
59
  cat <<'JSON'
56
- [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}]
60
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
57
61
  JSON
58
62
  `,
59
63
  )
@@ -220,11 +224,86 @@ pr: null
220
224
  state: "merged",
221
225
  draft: false,
222
226
  merged: true,
227
+ mergeable: true,
223
228
  },
224
229
  claim: { state: "released", sessionId: "session-1" },
225
230
  })
226
231
  })
227
232
 
233
+ test("records a conflicting open PR without changing lifecycle status", async () => {
234
+ await runTestEffect(
235
+ TaskService.pipe(
236
+ Effect.flatMap((service) =>
237
+ service.create(
238
+ {
239
+ id: "example",
240
+ ticketUrl: null,
241
+ repo: "agency",
242
+ branch: "feat/example",
243
+ base: "main",
244
+ },
245
+ root,
246
+ ),
247
+ ),
248
+ ),
249
+ )
250
+ await runTestEffect(
251
+ WorktreeService.pipe(
252
+ Effect.flatMap((service) =>
253
+ service.materialize("example", undefined, root),
254
+ ),
255
+ ),
256
+ )
257
+ await git(
258
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
259
+ join(root, "repos/agency"),
260
+ )
261
+ await runTestEffect(
262
+ PullRequestService.pipe(
263
+ Effect.flatMap((service) =>
264
+ service.setUrl(
265
+ "example",
266
+ undefined,
267
+ "https://github.com/example/agency/pull/42",
268
+ root,
269
+ ),
270
+ ),
271
+ ),
272
+ )
273
+ await Bun.write(
274
+ join(root, "bin", "gh"),
275
+ `#!/bin/sh
276
+ cat <<'JSON'
277
+ {"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
278
+ JSON
279
+ `,
280
+ )
281
+ await chmod(join(root, "bin", "gh"), 0o755)
282
+
283
+ const applied = await runTestEffect(
284
+ SyncService.pipe(
285
+ Effect.flatMap((service) =>
286
+ service.reconcile({ cwd: root, apply: true }),
287
+ ),
288
+ ),
289
+ )
290
+ expect(applied.changes).toContainEqual(
291
+ expect.objectContaining({ kind: "record-pr", target: "task:example" }),
292
+ )
293
+ expect(applied.changes.some((change) => change.kind === "mark-done")).toBe(
294
+ false,
295
+ )
296
+ const task = await runTestEffect(
297
+ TaskService.pipe(
298
+ Effect.flatMap((service) => service.show("example", root)),
299
+ ),
300
+ )
301
+ expect(task.data).toMatchObject({
302
+ status: "open",
303
+ pr: { state: "open", merged: false, mergeable: false },
304
+ })
305
+ })
306
+
228
307
  test("queries and records a configured delivery provider", async () => {
229
308
  await runTestEffect(
230
309
  TaskService.pipe(
@@ -689,7 +689,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
689
689
  "view",
690
690
  existing.url,
691
691
  "--json",
692
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
692
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
693
693
  ])
694
694
  if (viewed.exitCode === 0) {
695
695
  const detail = parseJson<Record<string, unknown>>(
@@ -729,7 +729,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
729
729
  "--state",
730
730
  "all",
731
731
  "--json",
732
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
732
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
733
733
  ],
734
734
  { cwd: repositoryPath },
735
735
  )
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import {
3
3
  parsePullRequestRecord,
4
+ recordFromGitHubJson,
4
5
  resolveDeliveryCommand,
5
6
  validateDelivery,
6
7
  } from "./delivery-command"
@@ -50,5 +51,37 @@ describe("delivery commands", () => {
50
51
  expect(() =>
51
52
  parsePullRequestRecord(JSON.stringify({ ...record, merged: false })),
52
53
  ).toThrow("inconsistent merge state")
54
+ expect(
55
+ parsePullRequestRecord(JSON.stringify({ ...record, mergeable: null })),
56
+ ).toEqual({ ...record, mergeable: null })
57
+ expect(() =>
58
+ parsePullRequestRecord(JSON.stringify({ ...record, mergeable: "yes" })),
59
+ ).toThrow("valid pull request record")
60
+ })
61
+
62
+ test("normalizes GitHub state and mergeability", () => {
63
+ const base = {
64
+ number: 17,
65
+ url: "https://github.com/example/agency/pull/17",
66
+ isDraft: false,
67
+ }
68
+ expect(
69
+ recordFromGitHubJson({ ...base, state: "OPEN", mergeable: "MERGEABLE" }),
70
+ ).toMatchObject({ state: "open", merged: false, mergeable: true })
71
+ expect(
72
+ recordFromGitHubJson({
73
+ ...base,
74
+ state: "OPEN",
75
+ mergeable: "CONFLICTING",
76
+ }),
77
+ ).toMatchObject({ state: "open", merged: false, mergeable: false })
78
+ expect(
79
+ recordFromGitHubJson({
80
+ ...base,
81
+ state: "CLOSED",
82
+ mergedAt: "2026-07-21T00:00:00Z",
83
+ mergeable: "UNKNOWN",
84
+ }),
85
+ ).toMatchObject({ state: "merged", merged: true, mergeable: null })
53
86
  })
54
87
  })
@@ -119,13 +119,20 @@ export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
119
119
  export const recordFromGitHubJson = (value: Record<string, unknown>) => {
120
120
  const url = typeof value.url === "string" ? value.url : ""
121
121
  const record = recordFromGitHubUrl(url)
122
- const state = String(value.state ?? "OPEN").toLowerCase()
122
+ const githubState = String(value.state ?? "OPEN").toLowerCase()
123
+ const merged = githubState === "merged" || value.mergedAt != null
124
+ const mergeable = String(value.mergeable ?? "UNKNOWN").toLowerCase()
123
125
  return {
124
126
  ...record,
125
- state:
126
- state === "merged" ? "merged" : state === "closed" ? "closed" : "open",
127
+ state: merged ? "merged" : githubState === "closed" ? "closed" : "open",
127
128
  draft: value.isDraft === true,
128
- merged: state === "merged" || value.mergedAt != null,
129
+ merged,
130
+ mergeable:
131
+ mergeable === "mergeable"
132
+ ? true
133
+ : mergeable === "conflicting"
134
+ ? false
135
+ : null,
129
136
  } satisfies PullRequestRecord
130
137
  }
131
138
 
@@ -185,11 +185,15 @@ describe("delivery configuration", () => {
185
185
  state: "open",
186
186
  draft: false,
187
187
  merged: false,
188
+ mergeable: false,
188
189
  },
189
190
  })
190
191
  expect(phase.pr && typeof phase.pr !== "string" && phase.pr.provider).toBe(
191
192
  "forge",
192
193
  )
194
+ expect(phase.pr && typeof phase.pr !== "string" && phase.pr.mergeable).toBe(
195
+ false,
196
+ )
193
197
  })
194
198
  })
195
199
 
@@ -72,6 +72,7 @@ export const PullRequestRecord = Schema.Struct({
72
72
  state: Schema.Literal("open", "closed", "merged"),
73
73
  draft: Schema.Boolean,
74
74
  merged: Schema.Boolean,
75
+ mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)),
75
76
  })
76
77
 
77
78
  const DeliveryProvider = Schema.Struct({