@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 +2 -2
- package/package.json +1 -1
- package/skills/agency/references/contracts.md +3 -1
- package/skills/agency/references/recipes.md +3 -2
- package/src/cli.test.ts +7 -1
- package/src/commands/work.test.ts +8 -5
- package/src/commands/work.ts +11 -1
- package/src/services/SyncService.test.ts +81 -2
- package/src/services/SyncService.ts +2 -2
- package/src/workbase/delivery-command.test.ts +33 -0
- package/src/workbase/delivery-command.ts +11 -4
- package/src/workbase/schemas.test.ts +4 -0
- package/src/workbase/schemas.ts +1 -0
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
|
|
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
|
@@ -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`,
|
|
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,
|
|
243
|
-
claim remains. It never modifies dirty
|
|
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(
|
|
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
|
-
|
|
492
|
-
)
|
|
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("
|
|
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).
|
|
929
|
+
expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBe(workbasePermission)
|
|
927
930
|
})
|
|
928
931
|
|
|
929
932
|
test("does not override customized OpenCode access policy", async () => {
|
package/src/commands/work.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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({
|