@markjaquith/agency 2.39.2 → 2.40.1
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 +13 -10
- package/package.json +1 -1
- package/skills/agency/SKILL.md +6 -5
- package/skills/agency/references/commands.md +2 -2
- package/skills/agency/references/contracts.md +5 -1
- package/skills/agency/references/recipes.md +8 -5
- package/src/cli.test.ts +13 -9
- package/src/commands/claim.ts +2 -1
- package/src/commands/phase.ts +1 -1
- package/src/commands/status.test.ts +1 -1
- package/src/commands/task-phase.test.ts +4 -4
- package/src/commands/task.ts +1 -1
- package/src/readiness.ts +3 -3
- package/src/services/ClaimService.test.ts +1 -1
- package/src/services/ClaimService.ts +5 -1
- package/src/services/IntegrationService.test.ts +4 -3
- package/src/services/PhaseService.ts +6 -0
- package/src/services/PullRequestService.test.ts +4 -2
- package/src/services/SyncService.test.ts +174 -2
- package/src/services/SyncService.ts +2 -2
- package/src/services/TaskPhaseService.test.ts +19 -20
- package/src/services/TaskService.ts +6 -0
- package/src/workbase/AGENTS.md +9 -7
- 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,
|
|
@@ -569,7 +569,7 @@ Targeted commands accept `--epic`, `--task`, and `--phase` where those entity
|
|
|
569
569
|
kinds apply. A phase selector requires a task selector. Entity selectors cannot
|
|
570
570
|
be mixed with positional target IDs, and an epic selector cannot be mixed with
|
|
571
571
|
task or phase selectors. This makes commands such as
|
|
572
|
-
`agency phase status
|
|
572
|
+
`agency phase status working --task ship --phase release --workbase primary --no-input`
|
|
573
573
|
fully independent of process cwd and prompts.
|
|
574
574
|
|
|
575
575
|
Inspect tasks:
|
|
@@ -577,7 +577,7 @@ Inspect tasks:
|
|
|
577
577
|
```text
|
|
578
578
|
agency task list [filters] [--json]
|
|
579
579
|
agency task show <id> [--json]
|
|
580
|
-
agency task status <id> <open|
|
|
580
|
+
agency task status <id> <open|working|dropped> [--json]
|
|
581
581
|
agency task update <id> [metadata options] [--json]
|
|
582
582
|
agency task rename <id> <new-id> [--json]
|
|
583
583
|
agency task move <id> (--epic <epic-id> | --no-epic) [--json]
|
|
@@ -616,7 +616,7 @@ agency phase create <task-id> <phase-id>
|
|
|
616
616
|
|
|
617
617
|
agency phase list <task-id> [filters] [--json]
|
|
618
618
|
agency phase show <task-id> <phase-id> [--json]
|
|
619
|
-
agency phase status <task-id> <phase-id> <open|
|
|
619
|
+
agency phase status <task-id> <phase-id> <open|working|dropped> [--json]
|
|
620
620
|
agency phase update <task-id> <phase-id> [metadata options] [--json]
|
|
621
621
|
agency phase rename <task-id> <phase-id> <new-id> [--json]
|
|
622
622
|
agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>
|
|
@@ -639,6 +639,8 @@ writing anything.
|
|
|
639
639
|
Single-phase tasks and phases store status in YAML. New execution units start
|
|
640
640
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
641
641
|
before launch. Running `agency work` again can relaunch unclaimed `working` work.
|
|
642
|
+
The `done` status is reserved for an authoritative merged pull request and is
|
|
643
|
+
applied by `agency sync --apply`, not by task or phase status commands.
|
|
642
644
|
Use explicit claims only when an external orchestrator needs coordinated
|
|
643
645
|
ownership. The interactive work selector displays status markers before
|
|
644
646
|
execution units. Existing working and delegated work may be released to `open`
|
|
@@ -672,11 +674,12 @@ agency finish <task-id> [phase-id] --session-id <id>
|
|
|
672
674
|
--revision <sha256> --outcome <done|dropped> [--json]
|
|
673
675
|
```
|
|
674
676
|
|
|
675
|
-
An active claim sets status to `working`. Release returns it to `open
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
677
|
+
An active claim sets status to `working`. Release returns it to `open`. Finish
|
|
678
|
+
records the claim outcome and ownership history; a `done` claim outcome leaves
|
|
679
|
+
the execution unit `working` until its pull request is merged, while `dropped`
|
|
680
|
+
remains terminal. Conflicts return the current revision and complete ownership
|
|
681
|
+
record in the machine error envelope rather than overwriting it. Expired claims
|
|
682
|
+
may be replaced with a revision-guarded claim.
|
|
680
683
|
|
|
681
684
|
`agency work` does not claim execution units. It refuses active explicit claims,
|
|
682
685
|
marks open execution work `working`, and launches the runner. External
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -118,11 +118,12 @@ independently meaningful tasks need coordination.
|
|
|
118
118
|
|
|
119
119
|
1. Re-run `agency validate` and repository checks.
|
|
120
120
|
2. Create a PR only when requested: `agency pr create <task> [phase]`.
|
|
121
|
-
3.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
3. Keep committed work `working` while its pull request is open. After the
|
|
122
|
+
authoritative pull request merges, use `agency sync --apply` to reconcile it
|
|
123
|
+
to `done`.
|
|
124
|
+
4. If the session has a claim, use revision-guarded `agency finish`; a successful
|
|
125
|
+
claim outcome closes ownership without marking unmerged work `done`. Use
|
|
126
|
+
`dropped` only for intentionally abandoned work.
|
|
126
127
|
5. Report the durable status and PR URL. Do not manually remove the worktree.
|
|
127
128
|
|
|
128
129
|
## Human Launch vs Active Agent
|
|
@@ -89,7 +89,7 @@ agency task create <id> --multi-phase [--ticket-url <url>]
|
|
|
89
89
|
[--description <text>] [--epic <id>] [--json]
|
|
90
90
|
agency task list [filters] [--json]
|
|
91
91
|
agency task show <id> [--json]
|
|
92
|
-
agency task status <id> <open|
|
|
92
|
+
agency task status <id> <open|working|dropped> [--json]
|
|
93
93
|
agency task update <id> [metadata options] [--if-revision <hash>] [--json]
|
|
94
94
|
agency task rename <id> <new-id> [--if-revision <hash>] [--json]
|
|
95
95
|
agency task move <id> (--epic <epic-id> | --no-epic)
|
|
@@ -102,7 +102,7 @@ agency phase create <task-id> <phase-id> --repo <alias> --branch <name>
|
|
|
102
102
|
[--depends-on <phase-id>...] [--first-phase <phase-id>] [--json]
|
|
103
103
|
agency phase list <task-id> [filters] [--json]
|
|
104
104
|
agency phase show <task-id> <phase-id> [--json]
|
|
105
|
-
agency phase status <task-id> <phase-id> <open|
|
|
105
|
+
agency phase status <task-id> <phase-id> <open|working|dropped> [--json]
|
|
106
106
|
agency phase update <task-id> <phase-id> [metadata options]
|
|
107
107
|
[--if-revision <hash>] [--json]
|
|
108
108
|
agency phase rename <task-id> <phase-id> <new-id>
|
|
@@ -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
|
|
|
@@ -135,6 +137,8 @@ readable legacy state but cannot be newly assigned.
|
|
|
135
137
|
- An execution unit has one writable `repo`; plural `repos` are read-only
|
|
136
138
|
`{ repo, ref }` entries and cannot repeat the writable alias.
|
|
137
139
|
- A writable `(repo, branch)` pair belongs to exactly one active execution unit.
|
|
140
|
+
- `done` represents an authoritative merged pull request and is reconciled by
|
|
141
|
+
`agency sync --apply`; committed or review-ready work remains `working`.
|
|
138
142
|
- Only `done` satisfies dependencies. `dropped` is terminal but blocks dependents.
|
|
139
143
|
- Epic task dependencies live in `EPIC.md`; phase dependencies live in `TASK.md`.
|
|
140
144
|
- IDs remain stable; use `dependsOn`, not numeric directory prefixes, for order.
|
|
@@ -184,15 +184,17 @@ agency release checkout ui --session-id session-123 \
|
|
|
184
184
|
|
|
185
185
|
## Finish Verified Work
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
After the claimed run completes successfully:
|
|
188
188
|
|
|
189
189
|
```bash
|
|
190
190
|
agency finish checkout ui --session-id session-123 \
|
|
191
191
|
--revision <current-sha256> --outcome done --json
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
Do not substitute
|
|
195
|
-
|
|
194
|
+
Do not substitute a phase status mutation for `finish`; `finish` preserves
|
|
195
|
+
ownership history and revision safety. A `done` claim outcome leaves unmerged
|
|
196
|
+
execution work `working`. After the authoritative pull request merges, use
|
|
197
|
+
`agency sync --apply` to reconcile the execution unit to `done`.
|
|
196
198
|
|
|
197
199
|
## Create A Pull Request
|
|
198
200
|
|
|
@@ -239,8 +241,9 @@ agency worktree repair <task-id> [phase-id] --dry-run --json
|
|
|
239
241
|
|
|
240
242
|
Use `agency sync --apply` only with explicit user intent. It may safely
|
|
241
243
|
materialize an unconflicted missing checkout, release an expired claim, record a
|
|
242
|
-
single matching PR,
|
|
243
|
-
claim remains. It never modifies dirty
|
|
244
|
+
single matching PR, refresh recorded PR state and mergeability, or mark work done
|
|
245
|
+
after its authoritative PR merged and no claim remains. It never modifies dirty
|
|
246
|
+
checkouts, switches branches, resets
|
|
244
247
|
references, chooses among PRs, or bypasses active claims.
|
|
245
248
|
|
|
246
249
|
For a worktree-specific issue, use `worktree repair --dry-run` before repair.
|
package/src/cli.test.ts
CHANGED
|
@@ -244,6 +244,10 @@ describe("CLI", () => {
|
|
|
244
244
|
),
|
|
245
245
|
)
|
|
246
246
|
expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
|
|
247
|
+
expect(
|
|
248
|
+
parseJson(await runCli(["task", "show", "claimed", "--json"], root)).data
|
|
249
|
+
.status,
|
|
250
|
+
).toBe("working")
|
|
247
251
|
})
|
|
248
252
|
|
|
249
253
|
test("routes graph mutations through structured output", async () => {
|
|
@@ -476,7 +480,7 @@ describe("CLI", () => {
|
|
|
476
480
|
)
|
|
477
481
|
}
|
|
478
482
|
parseJson(
|
|
479
|
-
await runCli(["task", "status", "finished", "
|
|
483
|
+
await runCli(["task", "status", "finished", "dropped", "--json"], root),
|
|
480
484
|
)
|
|
481
485
|
|
|
482
486
|
const human = await runCli(["next"], root)
|
|
@@ -490,9 +494,9 @@ describe("CLI", () => {
|
|
|
490
494
|
expect(result.excluded).toMatchObject([
|
|
491
495
|
{
|
|
492
496
|
key: "task/finished",
|
|
493
|
-
status: "
|
|
497
|
+
status: "dropped",
|
|
494
498
|
terminal: true,
|
|
495
|
-
blockers: [{ kind: "status", reason: "Task status is
|
|
499
|
+
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
496
500
|
},
|
|
497
501
|
])
|
|
498
502
|
|
|
@@ -504,8 +508,8 @@ describe("CLI", () => {
|
|
|
504
508
|
error: {
|
|
505
509
|
code: "EXECUTION_BLOCKED",
|
|
506
510
|
fields: {
|
|
507
|
-
status: "
|
|
508
|
-
blockers: [{ kind: "status", reason: "Task status is
|
|
511
|
+
status: "dropped",
|
|
512
|
+
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
509
513
|
},
|
|
510
514
|
},
|
|
511
515
|
})
|
|
@@ -1131,18 +1135,18 @@ status: open
|
|
|
1131
1135
|
}
|
|
1132
1136
|
|
|
1133
1137
|
parseJson(
|
|
1134
|
-
await runCli(["task", "status", "example", "
|
|
1138
|
+
await runCli(["task", "status", "example", "dropped", "--json"], root),
|
|
1135
1139
|
)
|
|
1136
1140
|
const blocked = await runCli(
|
|
1137
1141
|
["work", "--task", "example", "--runner", "noop"],
|
|
1138
1142
|
root,
|
|
1139
1143
|
)
|
|
1140
1144
|
expect(blocked.exitCode).toBe(1)
|
|
1141
|
-
expect(blocked.stderr).toContain("Task status is
|
|
1145
|
+
expect(blocked.stderr).toContain("Task status is dropped")
|
|
1142
1146
|
expect(
|
|
1143
1147
|
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
|
1144
1148
|
.data.status,
|
|
1145
|
-
).toBe("
|
|
1149
|
+
).toBe("dropped")
|
|
1146
1150
|
|
|
1147
1151
|
const resumedTask = await runCli(
|
|
1148
1152
|
["work", "--task", "example", "--runner", "noop", "--force"],
|
|
@@ -1150,7 +1154,7 @@ status: open
|
|
|
1150
1154
|
)
|
|
1151
1155
|
expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
|
|
1152
1156
|
expect(resumedTask.stdout).toContain(
|
|
1153
|
-
"Reopened task/example from
|
|
1157
|
+
"Reopened task/example from dropped as working",
|
|
1154
1158
|
)
|
|
1155
1159
|
expect(
|
|
1156
1160
|
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
package/src/commands/claim.ts
CHANGED
|
@@ -93,5 +93,6 @@ Release an execution unit owned by the session.
|
|
|
93
93
|
export const finishHelp = `
|
|
94
94
|
Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
|
|
95
95
|
|
|
96
|
-
Finish
|
|
96
|
+
Finish a claim owned by the session. A done claim outcome leaves unmerged work
|
|
97
|
+
working; agency sync --apply marks the execution unit done after merge.
|
|
97
98
|
`
|
package/src/commands/phase.ts
CHANGED
|
@@ -275,7 +275,7 @@ Subcommands:
|
|
|
275
275
|
list <task> List task phases
|
|
276
276
|
show <task> <phase> Show a phase
|
|
277
277
|
status <task> <phase> <status>
|
|
278
|
-
Set open, working,
|
|
278
|
+
Set open, working, or dropped
|
|
279
279
|
update <task> <phase> Update phase metadata
|
|
280
280
|
rename <task> <phase> <new-id>
|
|
281
281
|
Rename a phase and update dependencies
|
|
@@ -239,13 +239,13 @@ describe("task and phase command JSON output", () => {
|
|
|
239
239
|
runTestEffect(
|
|
240
240
|
phase({
|
|
241
241
|
subcommand: "status",
|
|
242
|
-
args: ["multi", "first", "
|
|
242
|
+
args: ["multi", "first", "working"],
|
|
243
243
|
cwd: root,
|
|
244
244
|
json: true,
|
|
245
245
|
}),
|
|
246
246
|
),
|
|
247
247
|
)
|
|
248
|
-
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("
|
|
248
|
+
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("working")
|
|
249
249
|
|
|
250
250
|
await runTestEffect(
|
|
251
251
|
task({
|
|
@@ -263,13 +263,13 @@ describe("task and phase command JSON output", () => {
|
|
|
263
263
|
runTestEffect(
|
|
264
264
|
task({
|
|
265
265
|
subcommand: "status",
|
|
266
|
-
args: ["single-status", "
|
|
266
|
+
args: ["single-status", "working"],
|
|
267
267
|
cwd: root,
|
|
268
268
|
json: true,
|
|
269
269
|
}),
|
|
270
270
|
),
|
|
271
271
|
)
|
|
272
|
-
expect(JSON.parse(taskLogs[0]!).data.status).toBe("
|
|
272
|
+
expect(JSON.parse(taskLogs[0]!).data.status).toBe("working")
|
|
273
273
|
})
|
|
274
274
|
|
|
275
275
|
test("converts a single-phase task with an explicit first phase ID", async () => {
|
package/src/commands/task.ts
CHANGED
|
@@ -423,7 +423,7 @@ Subcommands:
|
|
|
423
423
|
create <id> Create a task without prompting
|
|
424
424
|
list List tasks
|
|
425
425
|
show <id> Show a task
|
|
426
|
-
status <id> <status> Set open, working,
|
|
426
|
+
status <id> <status> Set open, working, or dropped
|
|
427
427
|
update <id> Update task metadata
|
|
428
428
|
rename <id> <new-id> Rename a task and update graph references
|
|
429
429
|
move <id> Move a task with --epic or --no-epic
|
package/src/readiness.ts
CHANGED
|
@@ -5,9 +5,9 @@ export interface ReadinessBlocker {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export const WORK_STATUS_TRANSITIONS = {
|
|
8
|
-
open: ["open", "working", "delegated", "
|
|
9
|
-
working: ["open", "working", "delegated", "
|
|
10
|
-
delegated: ["open", "working", "delegated", "
|
|
8
|
+
open: ["open", "working", "delegated", "dropped"],
|
|
9
|
+
working: ["open", "working", "delegated", "dropped"],
|
|
10
|
+
delegated: ["open", "working", "delegated", "dropped"],
|
|
11
11
|
done: ["open", "done"],
|
|
12
12
|
dropped: ["open", "dropped"],
|
|
13
13
|
} as const satisfies Record<WorkStatus, readonly WorkStatus[]>
|
|
@@ -139,7 +139,7 @@ describe("claim service", () => {
|
|
|
139
139
|
),
|
|
140
140
|
),
|
|
141
141
|
)
|
|
142
|
-
expect(finished.data.status).toBe("
|
|
142
|
+
expect(finished.data.status).toBe("working")
|
|
143
143
|
expect(finished.claim).toMatchObject({
|
|
144
144
|
state: "finished",
|
|
145
145
|
finishedAt: "2026-07-17T12:45:00.000Z",
|
|
@@ -538,7 +538,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
538
538
|
outcome: input.outcome,
|
|
539
539
|
}
|
|
540
540
|
return {
|
|
541
|
-
data: {
|
|
541
|
+
data: {
|
|
542
|
+
...data,
|
|
543
|
+
status: input.outcome === "done" ? "working" : "dropped",
|
|
544
|
+
claim,
|
|
545
|
+
},
|
|
542
546
|
claim,
|
|
543
547
|
}
|
|
544
548
|
},
|
|
@@ -80,14 +80,15 @@ describe("IntegrationService", () => {
|
|
|
80
80
|
expect(body).toContain("External orchestrators claim before launching")
|
|
81
81
|
expect(body).toContain("Run `agency validate`")
|
|
82
82
|
expect(body).toContain("only with explicit user intent")
|
|
83
|
-
expect(body).toContain("An execution unit
|
|
83
|
+
expect(body).toContain("An execution unit remains `working`")
|
|
84
84
|
expect(body).toContain("It becomes `done`")
|
|
85
|
-
expect(body).toContain("
|
|
85
|
+
expect(body).toContain("Do not mark committed")
|
|
86
86
|
expect(body).toContain("creating or updating a PR")
|
|
87
87
|
expect(body).toContain("marking it ready")
|
|
88
88
|
expect(body).toMatch(/completing\s+a refinement loop/)
|
|
89
89
|
expect(body).toContain("pausing or handing off")
|
|
90
|
-
expect(body).toContain("`agency
|
|
90
|
+
expect(body).toContain("`agency finish`")
|
|
91
|
+
expect(body).toContain("`agency sync --apply`")
|
|
91
92
|
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
92
93
|
expect(body).toContain("PR state, current head, diff summary")
|
|
93
94
|
expect(body).toContain("Run `agency validate` before reporting completion")
|
|
@@ -487,6 +487,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
487
487
|
})
|
|
488
488
|
}
|
|
489
489
|
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
490
|
+
if (validStatus === "done") {
|
|
491
|
+
return yield* new PhaseError({
|
|
492
|
+
message:
|
|
493
|
+
"Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
|
|
494
|
+
})
|
|
495
|
+
}
|
|
490
496
|
return yield* new PhaseError({
|
|
491
497
|
message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
492
498
|
})
|
|
@@ -263,11 +263,13 @@ process.exit(${exitCode})
|
|
|
263
263
|
await createTask()
|
|
264
264
|
await runTestEffect(
|
|
265
265
|
TaskService.pipe(
|
|
266
|
-
Effect.flatMap((service) =>
|
|
266
|
+
Effect.flatMap((service) =>
|
|
267
|
+
service.setStatus("example", "dropped", root),
|
|
268
|
+
),
|
|
267
269
|
),
|
|
268
270
|
)
|
|
269
271
|
|
|
270
|
-
await expect(createPullRequest()).rejects.toThrow("Task status is
|
|
272
|
+
await expect(createPullRequest()).rejects.toThrow("Task status is dropped")
|
|
271
273
|
expect(
|
|
272
274
|
await Bun.file(join(root, "tasks/example/code/agency")).exists(),
|
|
273
275
|
).toBe(false)
|
|
@@ -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(
|
|
@@ -311,6 +390,99 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
311
390
|
expect("pr" in task.data && task.data.pr).toEqual(record)
|
|
312
391
|
})
|
|
313
392
|
|
|
393
|
+
test("marks a successfully finished claim done only after merge", async () => {
|
|
394
|
+
await runTestEffect(
|
|
395
|
+
TaskService.pipe(
|
|
396
|
+
Effect.flatMap((service) =>
|
|
397
|
+
service.create(
|
|
398
|
+
{
|
|
399
|
+
id: "finished-claim",
|
|
400
|
+
ticketUrl: null,
|
|
401
|
+
repo: "agency",
|
|
402
|
+
branch: "feat/example",
|
|
403
|
+
base: "main",
|
|
404
|
+
},
|
|
405
|
+
root,
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
await runTestEffect(
|
|
411
|
+
WorktreeService.pipe(
|
|
412
|
+
Effect.flatMap((service) =>
|
|
413
|
+
service.materialize("finished-claim", undefined, root),
|
|
414
|
+
),
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
await git(
|
|
418
|
+
["remote", "set-url", "origin", "git@github.com:example/agency.git"],
|
|
419
|
+
join(root, "repos/agency"),
|
|
420
|
+
)
|
|
421
|
+
const initial = await runTestEffect(
|
|
422
|
+
ClaimService.pipe(
|
|
423
|
+
Effect.flatMap((service) =>
|
|
424
|
+
service.inspect("finished-claim", undefined, root),
|
|
425
|
+
),
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
const claimed = await runTestEffect(
|
|
429
|
+
ClaimService.pipe(
|
|
430
|
+
Effect.flatMap((service) =>
|
|
431
|
+
service.claim(
|
|
432
|
+
{
|
|
433
|
+
taskId: "finished-claim",
|
|
434
|
+
claimant: "orchestrator",
|
|
435
|
+
runner: "agent",
|
|
436
|
+
sessionId: "session-1",
|
|
437
|
+
revision: initial.revision,
|
|
438
|
+
},
|
|
439
|
+
root,
|
|
440
|
+
),
|
|
441
|
+
),
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
const finished = await runTestEffect(
|
|
445
|
+
ClaimService.pipe(
|
|
446
|
+
Effect.flatMap((service) =>
|
|
447
|
+
service.finish(
|
|
448
|
+
{
|
|
449
|
+
taskId: "finished-claim",
|
|
450
|
+
sessionId: "session-1",
|
|
451
|
+
revision: claimed.revision,
|
|
452
|
+
outcome: "done",
|
|
453
|
+
},
|
|
454
|
+
root,
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
expect(finished.data).toMatchObject({
|
|
460
|
+
status: "working",
|
|
461
|
+
claim: { state: "finished", outcome: "done" },
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const synced = await runTestEffect(
|
|
465
|
+
SyncService.pipe(
|
|
466
|
+
Effect.flatMap((service) =>
|
|
467
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
expect(synced.changes.map((change) => change.kind)).toContain("mark-done")
|
|
472
|
+
expect(
|
|
473
|
+
await runTestEffect(
|
|
474
|
+
TaskService.pipe(
|
|
475
|
+
Effect.flatMap((service) => service.show("finished-claim", root)),
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
).toMatchObject({
|
|
479
|
+
data: {
|
|
480
|
+
status: "done",
|
|
481
|
+
claim: { state: "finished", outcome: "done" },
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
314
486
|
test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
|
|
315
487
|
for (const [id, branch] of [
|
|
316
488
|
["missing", "feat/missing"],
|
|
@@ -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
|
)
|
|
@@ -318,22 +318,23 @@ describe("task and phase services", () => {
|
|
|
318
318
|
),
|
|
319
319
|
),
|
|
320
320
|
).rejects.toThrow("Delegation requires explicit ownership")
|
|
321
|
-
await runTestEffect(
|
|
322
|
-
TaskService.pipe(
|
|
323
|
-
Effect.flatMap((service) =>
|
|
324
|
-
service.setStatus("single-status", "done", root),
|
|
325
|
-
),
|
|
326
|
-
),
|
|
327
|
-
)
|
|
328
321
|
await expect(
|
|
329
322
|
runTestEffect(
|
|
330
323
|
TaskService.pipe(
|
|
331
324
|
Effect.flatMap((service) =>
|
|
332
|
-
service.setStatus("single-status", "
|
|
325
|
+
service.setStatus("single-status", "done", root),
|
|
333
326
|
),
|
|
334
327
|
),
|
|
335
328
|
),
|
|
336
|
-
).rejects.toThrow("
|
|
329
|
+
).rejects.toThrow("authoritative pull request is merged")
|
|
330
|
+
const droppedTask = await runTestEffect(
|
|
331
|
+
TaskService.pipe(
|
|
332
|
+
Effect.flatMap((service) =>
|
|
333
|
+
service.setStatus("single-status", "dropped", root),
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
expect(droppedTask.data.status).toBe("dropped")
|
|
337
338
|
|
|
338
339
|
await runTestEffect(
|
|
339
340
|
TaskService.pipe(
|
|
@@ -389,7 +390,7 @@ describe("task and phase services", () => {
|
|
|
389
390
|
),
|
|
390
391
|
),
|
|
391
392
|
),
|
|
392
|
-
).rejects.toThrow("
|
|
393
|
+
).rejects.toThrow("authoritative pull request is merged")
|
|
393
394
|
await runTestEffect(
|
|
394
395
|
PhaseService.pipe(
|
|
395
396
|
Effect.flatMap((service) =>
|
|
@@ -397,17 +398,15 @@ describe("task and phase services", () => {
|
|
|
397
398
|
),
|
|
398
399
|
),
|
|
399
400
|
)
|
|
400
|
-
expect(
|
|
401
|
-
(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
service.setStatus("multi-status", "implementation", "done", root),
|
|
406
|
-
),
|
|
401
|
+
await expect(
|
|
402
|
+
runTestEffect(
|
|
403
|
+
PhaseService.pipe(
|
|
404
|
+
Effect.flatMap((service) =>
|
|
405
|
+
service.setStatus("multi-status", "implementation", "done", root),
|
|
407
406
|
),
|
|
408
|
-
)
|
|
409
|
-
)
|
|
410
|
-
).
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
).rejects.toThrow("authoritative pull request is merged")
|
|
411
410
|
await expect(
|
|
412
411
|
runTestEffect(
|
|
413
412
|
TaskService.pipe(
|
|
@@ -270,6 +270,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
270
270
|
})
|
|
271
271
|
}
|
|
272
272
|
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
273
|
+
if (validStatus === "done") {
|
|
274
|
+
return yield* new TaskError({
|
|
275
|
+
message:
|
|
276
|
+
"Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
|
|
277
|
+
})
|
|
278
|
+
}
|
|
273
279
|
return yield* new TaskError({
|
|
274
280
|
message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
275
281
|
})
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -48,17 +48,19 @@ reason to edit `agency.json` or `repos/` by hand.
|
|
|
48
48
|
|
|
49
49
|
## Closeout
|
|
50
50
|
|
|
51
|
-
An execution unit
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
An execution unit remains `working` after implementation is committed and while
|
|
52
|
+
its pull request is open. It becomes `done` only after its authoritative pull
|
|
53
|
+
request is merged and Agency reconciles that state. Do not mark committed or
|
|
54
|
+
review-ready work `done` manually.
|
|
55
55
|
|
|
56
56
|
At each closeout trigger (creating or updating a PR, marking it ready, completing
|
|
57
57
|
a refinement loop, or pausing or handing off completed implementation work):
|
|
58
58
|
|
|
59
|
-
- Finish an active claim with the current revision via `agency finish
|
|
60
|
-
|
|
61
|
-
execution unit
|
|
59
|
+
- Finish an active claim with the current revision via `agency finish`; a
|
|
60
|
+
successful claim outcome leaves unmerged work `working`. For unclaimed work,
|
|
61
|
+
keep the execution unit `working` through review and merge.
|
|
62
|
+
- After merge, run `agency sync --apply` to reconcile the execution unit to
|
|
63
|
+
`done`.
|
|
62
64
|
- Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
|
|
63
65
|
PR state, current head, diff summary, and verification results after later
|
|
64
66
|
pushes when those details are maintained there.
|
|
@@ -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({
|