@markjaquith/agency 3.2.2 → 3.2.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.
Files changed (48) hide show
  1. package/README.md +30 -62
  2. package/cli-main.ts +20 -83
  3. package/fixtures/protocol/orchestration-recipes.json +9 -34
  4. package/package.json +1 -4
  5. package/schemas/agency-graph-v1.schema.json +2 -31
  6. package/src/cli-parser.test.ts +34 -132
  7. package/src/cli-parser.ts +45 -102
  8. package/src/cli.test.ts +60 -117
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/sync.ts +3 -3
  11. package/src/commands/validate.ts +3 -1
  12. package/src/commands/work.test.ts +70 -8
  13. package/src/commands/work.ts +15 -8
  14. package/src/graph-schema.ts +0 -2
  15. package/src/protocol.test.ts +0 -25
  16. package/src/protocol.ts +0 -11
  17. package/src/readiness.test.ts +2 -2
  18. package/src/services/ArchiveBulkService.test.ts +0 -88
  19. package/src/services/ArchiveService.ts +0 -32
  20. package/src/services/GraphMutationService.ts +0 -26
  21. package/src/services/GraphService.test.ts +1 -1
  22. package/src/services/IntegrationService.test.ts +7 -4
  23. package/src/services/LifecycleTransaction.ts +5 -1
  24. package/src/services/PhaseService.ts +1 -8
  25. package/src/services/ReadinessService.test.ts +0 -34
  26. package/src/services/ReadinessService.ts +1 -20
  27. package/src/services/ReviewService.test.ts +0 -64
  28. package/src/services/ReviewService.ts +0 -5
  29. package/src/services/SyncService.test.ts +75 -88
  30. package/src/services/SyncService.ts +52 -123
  31. package/src/services/TaskPhaseService.test.ts +13 -1
  32. package/src/services/TaskService.ts +1 -7
  33. package/src/services/WorkbaseService.ts +0 -3
  34. package/src/services/WorktreeService.test.ts +192 -1
  35. package/src/services/WorktreeService.ts +169 -34
  36. package/src/test-utils.ts +0 -2
  37. package/src/usage-log.test.ts +89 -5
  38. package/src/usage-log.ts +139 -31
  39. package/src/workbase/AGENTS.md +9 -15
  40. package/src/workbase/agent-command.test.ts +1 -3
  41. package/src/workbase/agent-command.ts +1 -6
  42. package/src/workbase/document-revision.ts +0 -4
  43. package/src/workbase/opencode-plugin-file.ts +1 -0
  44. package/src/workbase/schemas.test.ts +12 -25
  45. package/src/workbase/schemas.ts +0 -16
  46. package/src/commands/claim.ts +0 -122
  47. package/src/services/ClaimService.test.ts +0 -415
  48. package/src/services/ClaimService.ts +0 -608
@@ -106,9 +106,6 @@ const entityChoices = (
106
106
  value: entityKey(node),
107
107
  }))
108
108
 
109
- const activeClaim = (node: EntityNode) =>
110
- "claim" in node.data && node.data.claim?.state === "active"
111
-
112
109
  const executionNode = (
113
110
  node: EntityNode,
114
111
  executions: ReadonlyMap<
@@ -131,7 +128,6 @@ const canWork = (
131
128
  const execution = executionNode(node, executions)
132
129
  return Boolean(
133
130
  execution &&
134
- !activeClaim(node) &&
135
131
  (execution.readiness.ready ||
136
132
  (execution.status === "working" &&
137
133
  execution.readiness.blockers.every(
@@ -168,10 +164,10 @@ const actionChoices = (
168
164
  if (canCreatePr(node, executions)) {
169
165
  choices.push({ key: "pr", label: "Create pull request", value: "pr" })
170
166
  }
171
- if (execution && isTerminalStatus(node.status) && !activeClaim(node)) {
167
+ if (execution && isTerminalStatus(node.status)) {
172
168
  choices.push({ key: "reopen", label: "Reopen", value: "reopen" })
173
169
  }
174
- if (execution && !isTerminalStatus(node.status) && !activeClaim(node)) {
170
+ if (execution && !isTerminalStatus(node.status)) {
175
171
  choices.push({ key: "drop", label: "Drop", value: "drop" })
176
172
  }
177
173
  if (node.readiness.terminal) {
@@ -125,7 +125,7 @@ export const help = `
125
125
  Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
126
126
 
127
127
  Compare portable repository declarations and execution state with local Git
128
- repositories, worktrees, branches, references, claims, and pull requests.
128
+ repositories, worktrees, branches, references, and pull requests.
129
129
  Safe reconciliation transitions are applied by default.
130
130
  When a task or phase is provided, only that target and its repositories are
131
131
  queried or reconciled. Task scope includes all phases of a multi-phase task.
@@ -135,7 +135,7 @@ Options:
135
135
  --json Output one versioned machine result
136
136
 
137
137
  Sync may materialize declared repositories and unambiguous missing checkouts,
138
- adopt legacy repositories with portable origins, release expired claims,
139
- record a uniquely matched PR, and mark merged work done. Dirty, stale, or
138
+ adopt legacy repositories with portable origins, record a uniquely matched PR,
139
+ and mark merged work done. Dirty, stale, or
140
140
  conflicting checkouts are always left unresolved.
141
141
  `
@@ -13,7 +13,9 @@ interface ValidateOptions extends BaseCommandOptions {
13
13
  readonly json?: boolean
14
14
  }
15
15
 
16
- class ValidationFailedError extends Data.TaggedError("ValidationFailedError")<{
16
+ export class ValidationFailedError extends Data.TaggedError(
17
+ "ValidationFailedError",
18
+ )<{
17
19
  readonly message: string
18
20
  readonly root: string
19
21
  readonly issues: readonly {
@@ -84,6 +84,8 @@ interface HarnessOptions {
84
84
  Record<string, "open" | "working" | "delegated" | "done" | "dropped">
85
85
  >
86
86
  readonly taskRepo?: string
87
+ readonly existingPaths?: readonly string[]
88
+ readonly validationIssues?: readonly { path: string; message: string }[]
87
89
  }
88
90
 
89
91
  const createHarness = (options: HarnessOptions = {}) => {
@@ -142,15 +144,17 @@ const createHarness = (options: HarnessOptions = {}) => {
142
144
  },
143
145
  }),
144
146
  repositoryAliases: () => Effect.succeed(["agency"]),
145
- validate: () =>
146
- Effect.succeed({
147
+ validate: () => {
148
+ const issues = options.validationIssues ?? []
149
+ return Effect.succeed({
147
150
  root: "/workbase",
148
- issues: [],
151
+ issues,
149
152
  epicCount: 0,
150
153
  taskCount: 1,
151
154
  phaseCount: 0,
152
- valid: true,
153
- }),
155
+ valid: issues.length === 0,
156
+ })
157
+ },
154
158
  }
155
159
  const epics = {
156
160
  show: (id: string) =>
@@ -281,7 +285,8 @@ const createHarness = (options: HarnessOptions = {}) => {
281
285
  readFile: (path: string) =>
282
286
  Effect.succeed(path.endsWith("agency.json") ? '{"version":2}\n' : ""),
283
287
  readDirectory: () => Effect.succeed([]),
284
- exists: () => Effect.succeed(false),
288
+ exists: (path: string) =>
289
+ Effect.succeed(options.existingPaths?.includes(path) ?? false),
285
290
  realPath: (path: string) => Effect.succeed(path),
286
291
  runCommand: (args: readonly string[]) => {
287
292
  const cli = args[1]!
@@ -569,6 +574,65 @@ describe("work command", () => {
569
574
  ).rejects.toThrow("Recalled repository conflicts")
570
575
  })
571
576
 
577
+ test("resolves task and phase document paths", async () => {
578
+ const taskDocument = "/workbase/tasks/example/TASK.md"
579
+ const taskHarness = createHarness({
580
+ existingDirectories: [],
581
+ existingPaths: [taskDocument],
582
+ })
583
+
584
+ await taskHarness.runPrepare({
585
+ directory: taskDocument,
586
+ dryRun: true,
587
+ silent: true,
588
+ })
589
+ expect(taskHarness.guards[0]?.target).toBe("execution-unit:task/example")
590
+ expect(taskHarness.shownTasks).toEqual(["example", "example"])
591
+
592
+ const phaseDocument =
593
+ "/workbase/tasks/example/phases/implementation/PHASE.md"
594
+ const phaseHarness = createHarness({
595
+ existingDirectories: [],
596
+ existingPaths: [phaseDocument],
597
+ multiPhaseTasks: ["example"],
598
+ workspace: multiPhaseWorkspace,
599
+ })
600
+
601
+ await phaseHarness.runPrepare({
602
+ directory: phaseDocument,
603
+ dryRun: true,
604
+ silent: true,
605
+ })
606
+ expect(phaseHarness.guards[0]?.target).toBe(
607
+ "execution-unit:phase/example/implementation",
608
+ )
609
+ expect(phaseHarness.materializeOptions).toHaveLength(1)
610
+ })
611
+
612
+ test("returns structured validation issues before readiness or materialization", async () => {
613
+ const harness = createHarness({
614
+ existingDirectories: [],
615
+ validationIssues: [
616
+ {
617
+ path: "tasks/example/TASK.md",
618
+ message: "Repository alias 'missing' is not configured",
619
+ },
620
+ ],
621
+ })
622
+
623
+ await expect(
624
+ harness.runPrepare({
625
+ cwd: "/workbase",
626
+ directory: "example",
627
+ dryRun: true,
628
+ }),
629
+ ).rejects.toThrow(
630
+ "Workbase validation failed with 1 issue:\n- tasks/example/TASK.md: Repository alias 'missing' is not configured",
631
+ )
632
+ expect(harness.guards).toEqual([])
633
+ expect(harness.events).toEqual([])
634
+ })
635
+
572
636
  test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
573
637
  const blocked = createHarness({
574
638
  workspace: multiPhaseWorkspace,
@@ -1031,12 +1095,10 @@ describe("work command", () => {
1031
1095
  })
1032
1096
  expect(harness.launchEnvironments[0]).toMatchObject({
1033
1097
  AGENCY_AGENT: "custom",
1034
- AGENCY_CLAIMANT: process.env.USER ?? "agency",
1035
1098
  AGENCY_WORKBASE: "/workbase",
1036
1099
  AGENCY_TARGET: "execution-unit:task/example",
1037
1100
  AGENCY_TASK_ID: "example",
1038
1101
  AGENCY_PHASE_ID: "",
1039
- AGENCY_CLAIM_REVISION: "",
1040
1102
  CUSTOM_TARGET: "execution-unit:task/example",
1041
1103
  })
1042
1104
  })
@@ -36,6 +36,7 @@ import {
36
36
  normalizeRecalledContext,
37
37
  readValidationEvidence,
38
38
  } from "../workbase/execution-contract"
39
+ import { ValidationFailedError } from "./validate"
39
40
 
40
41
  export interface WorkOptions extends BaseCommandOptions {
41
42
  readonly directory?: string
@@ -288,7 +289,6 @@ export const work = (
288
289
  const defaultAgents = ["opencode2", "opencode", "pi", "claude"] as const
289
290
  let defaultAgentIndex = 0
290
291
  let agent: string = selectedAgent ?? defaultAgents[defaultAgentIndex]!
291
- const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
292
292
  const sessionId =
293
293
  process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
294
294
  const resume =
@@ -300,9 +300,7 @@ export const work = (
300
300
  target: targetNodeId(target),
301
301
  task: target.kind === "epic" ? "" : target.taskId,
302
302
  phase: target.kind === "phase" ? target.phaseId : "",
303
- claimant,
304
303
  sessionId,
305
- claimRevision: "",
306
304
  }
307
305
  let resolved = resolveAgentCommand(
308
306
  agent,
@@ -439,7 +437,8 @@ export const workPrepare = (options: WorkOptions = {}) =>
439
437
  const cwd = options.cwd ?? process.cwd()
440
438
  const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
441
439
  const isDirectory = yield* fs.isDirectory(targetPath)
442
- const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
440
+ const targetExists = isDirectory || (yield* fs.exists(targetPath))
441
+ const root = yield* workbase.discover(targetExists ? targetPath : cwd)
443
442
 
444
443
  let taskId = options.taskId
445
444
  let phaseId = options.phaseId
@@ -447,7 +446,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
447
446
  const task = yield* tasks.show(taskId, root)
448
447
  taskId = task.id
449
448
  if (phaseId) phaseId = (yield* phases.show(task.id, phaseId, root)).id
450
- } else if (options.directory && !isDirectory) {
449
+ } else if (options.directory && !targetExists) {
451
450
  const task = yield* tasks.show(options.directory, root)
452
451
  taskId = task.id
453
452
  } else {
@@ -526,9 +525,17 @@ export const workPrepare = (options: WorkOptions = {}) =>
526
525
  })
527
526
  let validation: unknown = { valid: true, source: "evidence" }
528
527
  if (assessment.disposition.status === "refreshed") {
529
- validation = yield* workbase.validate(root)
530
- if (!(validation as { valid: boolean }).valid && !options.force) {
531
- return yield* Effect.fail(new Error("Workbase validation failed"))
528
+ const report = yield* workbase.validate(root)
529
+ validation = report
530
+ if (!report.valid && !options.force) {
531
+ const details = report.issues
532
+ .map((issue) => `- ${issue.path}: ${issue.message}`)
533
+ .join("\n")
534
+ return yield* new ValidationFailedError({
535
+ message: `Workbase validation failed with ${report.issues.length} issue${report.issues.length === 1 ? "" : "s"}:\n${details}`,
536
+ root: report.root,
537
+ issues: report.issues,
538
+ })
532
539
  }
533
540
  }
534
541
  yield* readiness.guardWorkTarget(target, root, options.force)
@@ -3,7 +3,6 @@ import {
3
3
  EpicFrontmatter,
4
4
  PhaseFrontmatter,
5
5
  PullRequestRecord,
6
- ClaimRecord,
7
6
  ReviewRecord,
8
7
  TaskHandoff,
9
8
  TaskFrontmatter,
@@ -96,7 +95,6 @@ export const GraphExecutionData = Schema.Union(
96
95
  handoff: Schema.optional(TaskHandoff),
97
96
  review: ReviewRecord,
98
97
  status: WorkStatus,
99
- claim: Schema.optional(ClaimRecord),
100
98
  }),
101
99
  )
102
100
 
@@ -116,31 +116,6 @@ describe("machine protocol", () => {
116
116
  "issues",
117
117
  "root",
118
118
  ])
119
- expect(
120
- errorEnvelope({
121
- _tag: "ClaimConflictError",
122
- message: "already claimed",
123
- target: "task 'example'",
124
- currentRevision: "a".repeat(64),
125
- claim: {
126
- claimant: "orchestrator",
127
- agent: "agent",
128
- sessionId: "job-1",
129
- startedAt: "2026-07-17T12:00:00.000Z",
130
- targetRevision: "0".repeat(64),
131
- state: "active",
132
- },
133
- }),
134
- ).toMatchObject({
135
- error: {
136
- code: "CLAIM_CONFLICT",
137
- retryable: true,
138
- fields: {
139
- target: "task 'example'",
140
- claim: { agent: "agent", sessionId: "job-1" },
141
- },
142
- },
143
- })
144
119
  expect(
145
120
  errorEnvelope({
146
121
  _tag: "RevisionConflictError",
package/src/protocol.ts CHANGED
@@ -81,22 +81,11 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
81
81
  EpicError: { code: "EPIC_ERROR", retryable: false },
82
82
  TaskError: { code: "TASK_ERROR", retryable: false },
83
83
  PhaseError: { code: "PHASE_ERROR", retryable: false },
84
- ClaimError: { code: "CLAIM_ERROR", retryable: false },
85
- ClaimConflictError: {
86
- code: "CLAIM_CONFLICT",
87
- retryable: true,
88
- remediation: "Inspect the current ownership details before retrying.",
89
- },
90
84
  RevisionConflictError: {
91
85
  code: "REVISION_CONFLICT",
92
86
  retryable: true,
93
87
  remediation: "Read the current document revision and retry intentionally.",
94
88
  },
95
- ClaimOwnershipError: {
96
- code: "CLAIM_OWNERSHIP",
97
- retryable: false,
98
- remediation: "Use the session that owns the claim.",
99
- },
100
89
  ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
101
90
  WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
102
91
  PushError: { code: "PUSH_ERROR", retryable: false },
@@ -27,10 +27,10 @@ describe("readiness model", () => {
27
27
  blockedBy: ["task:one"],
28
28
  terminal: true,
29
29
  })
30
- expect(readinessState("working", [{ id: "claim:self" }])).toEqual({
30
+ expect(readinessState("working", [{ id: "dependency:self" }])).toEqual({
31
31
  ready: false,
32
32
  blocked: true,
33
- blockedBy: ["claim:self"],
33
+ blockedBy: ["dependency:self"],
34
34
  terminal: false,
35
35
  })
36
36
  })
@@ -335,94 +335,6 @@ describe("ArchiveService bulk task archive", () => {
335
335
  ).toBe(true)
336
336
  })
337
337
 
338
- test("skips task- and phase-level active claims", async () => {
339
- await createTask("claimed")
340
- await dropTask("claimed")
341
- const taskPath = join(root, "tasks/claimed/TASK.md")
342
- await Bun.write(
343
- taskPath,
344
- (await Bun.file(taskPath).text()).replace(
345
- "status: dropped\n",
346
- `status: dropped
347
- claim:
348
- claimant: orchestrator
349
- agent: opencode
350
- sessionId: task-session
351
- startedAt: 2026-08-07T00:00:00.000Z
352
- targetRevision: ${"a".repeat(64)}
353
- state: active
354
- `,
355
- ),
356
- )
357
- await runTestEffect(
358
- TaskService.pipe(
359
- Effect.flatMap((service) =>
360
- service.create(
361
- { id: "multi-claimed", ticketUrl: null, multiPhase: true },
362
- root,
363
- ),
364
- ),
365
- ),
366
- )
367
- await runTestEffect(
368
- PhaseService.pipe(
369
- Effect.flatMap((service) =>
370
- service.create(
371
- {
372
- taskId: "multi-claimed",
373
- id: "phase",
374
- repo: "agency",
375
- branch: "task/multi-claimed",
376
- base: "main",
377
- },
378
- root,
379
- ),
380
- ),
381
- ),
382
- )
383
- await runTestEffect(
384
- PhaseService.pipe(
385
- Effect.flatMap((service) =>
386
- service.setStatus("multi-claimed", "phase", "dropped", root),
387
- ),
388
- ),
389
- )
390
- const phasePath = join(root, "tasks/multi-claimed/phases/phase/PHASE.md")
391
- await Bun.write(
392
- phasePath,
393
- (await Bun.file(phasePath).text()).replace(
394
- "status: dropped\n",
395
- `status: dropped
396
- claim:
397
- claimant: orchestrator
398
- agent: opencode
399
- sessionId: phase-session
400
- startedAt: 2026-08-07T00:00:00.000Z
401
- targetRevision: ${"b".repeat(64)}
402
- state: active
403
- `,
404
- ),
405
- )
406
-
407
- const result = await archiveTasks(true)
408
-
409
- expect(result.tasks).toMatchObject([
410
- {
411
- id: "claimed",
412
- disposition: "skipped",
413
- reason: { code: "active-claim", details: ["task:claimed"] },
414
- },
415
- {
416
- id: "multi-claimed",
417
- disposition: "skipped",
418
- reason: {
419
- code: "active-claim",
420
- details: ["phase:multi-claimed/phase"],
421
- },
422
- },
423
- ])
424
- })
425
-
426
338
  test("rolls back the entire cohort when application fails", async () => {
427
339
  await runTestEffect(
428
340
  EpicService.pipe(
@@ -111,7 +111,6 @@ interface LifecycleOptions {
111
111
 
112
112
  type TaskArchiveSkipCode =
113
113
  | "non-terminal"
114
- | "active-claim"
115
114
  | "dirty-worktree"
116
115
  | "checkout-preflight-failed"
117
116
  | "retained-dependent"
@@ -169,7 +168,6 @@ interface TaskArchiveContext {
169
168
  readonly executionUnits: readonly { taskId: string; phaseId?: string }[]
170
169
  readonly terminal: boolean
171
170
  readonly terminalDetails: readonly string[]
172
- readonly activeClaims: readonly string[]
173
171
  }
174
172
 
175
173
  const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
@@ -182,8 +180,6 @@ const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
182
180
  executionUnits: [{ taskId: task.id }],
183
181
  terminal: isTerminalStatus(task.data.status),
184
182
  terminalDetails: [`status=${task.data.status}`],
185
- activeClaims:
186
- task.data.claim?.state === "active" ? [`task:${task.id}`] : [],
187
183
  } satisfies TaskArchiveContext
188
184
  }
189
185
 
@@ -207,9 +203,6 @@ const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
207
203
  : phaseRecords.map(
208
204
  (phase) => `phase:${phase.id}:status=${phase.data.status}`,
209
205
  ),
210
- activeClaims: phaseRecords
211
- .filter((phase) => phase.data.claim?.state === "active")
212
- .map((phase) => `phase:${task.id}/${phase.id}`),
213
206
  } satisfies TaskArchiveContext
214
207
  })
215
208
 
@@ -217,9 +210,6 @@ const archiveEligibilityError = (context: TaskArchiveContext) => {
217
210
  if (!context.terminal) {
218
211
  return `Task '${context.task.id}' is not terminal (${context.terminalDetails.join(", ")}); only done or dropped tasks can be archived`
219
212
  }
220
- if (context.activeClaims.length > 0) {
221
- return `Task '${context.task.id}' has active claims (${context.activeClaims.join(", ")}); release or finish them before archiving`
222
- }
223
213
  return undefined
224
214
  }
225
215
 
@@ -735,11 +725,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
735
725
  const executionUnits: { taskId: string; phaseId?: string }[] = []
736
726
  const phaseRecords: PhaseRecord[] = []
737
727
  for (const task of taskRecords) {
738
- if ("claim" in task.data && task.data.claim?.state === "active") {
739
- return yield* new ArchiveError({
740
- message: `Task '${task.id}' has an active claim; release or finish it before archiving`,
741
- })
742
- }
743
728
  if ("phases" in task.data) {
744
729
  for (const phase of task.data.phases) {
745
730
  const record = yield* (yield* PhaseService).show(
@@ -747,11 +732,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
747
732
  phase.id,
748
733
  root,
749
734
  )
750
- if (record.data.claim?.state === "active") {
751
- return yield* new ArchiveError({
752
- message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
753
- })
754
- }
755
735
  phaseRecords.push(record)
756
736
  executionUnits.push({ taskId: task.id, phaseId: phase.id })
757
737
  }
@@ -910,13 +890,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
910
890
  })
911
891
  continue
912
892
  }
913
- if (context.activeClaims.length > 0) {
914
- skipped.set(task.id, {
915
- code: "active-claim",
916
- details: context.activeClaims,
917
- })
918
- continue
919
- }
920
893
  const destination = archivedTaskDirectory(root, task.id)
921
894
  if (yield* fs.exists(destination)) {
922
895
  skipped.set(task.id, {
@@ -1398,11 +1371,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1398
1371
  const declaration = task.data.phases.find(
1399
1372
  (candidate) => candidate.id === id,
1400
1373
  )!
1401
- if (phase.data.claim?.state === "active") {
1402
- return yield* new ArchiveError({
1403
- message: `Phase '${id}' has an active claim; release or finish it before archiving`,
1404
- })
1405
- }
1406
1374
  const dependent = task.data.phases.find((candidate) =>
1407
1375
  candidate.dependsOn?.includes(id),
1408
1376
  )
@@ -376,15 +376,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
376
376
  message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
377
377
  })
378
378
  }
379
- if (
380
- executionChange &&
381
- "claim" in record.data &&
382
- record.data.claim?.state === "active"
383
- ) {
384
- return yield* new GraphMutationError({
385
- message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
386
- })
387
- }
388
379
  if (
389
380
  updates.pr &&
390
381
  "completion" in record.data &&
@@ -526,11 +517,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
526
517
  message: `Phase '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
527
518
  })
528
519
  }
529
- if (executionChange && record.data.claim?.state === "active") {
530
- return yield* new GraphMutationError({
531
- message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
532
- })
533
- }
534
520
  if (updates.pr && record.data.completion) {
535
521
  return yield* new GraphMutationError({
536
522
  message:
@@ -842,10 +828,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
842
828
  return yield* new GraphMutationError({
843
829
  message: `Task '${newId}' already exists`,
844
830
  })
845
- if ("claim" in task.data && task.data.claim?.state === "active")
846
- return yield* new GraphMutationError({
847
- message: `Task '${id}' has an active claim; release or finish it before renaming`,
848
- })
849
831
  if (yield* fs.isDirectory(join(from, "code")))
850
832
  return yield* new GraphMutationError({
851
833
  message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
@@ -859,10 +841,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
859
841
  root,
860
842
  )
861
843
  movedPhases.push(record)
862
- if (record.data.claim?.state === "active")
863
- return yield* new GraphMutationError({
864
- message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
865
- })
866
844
  if (yield* fs.isDirectory(join(dirname(record.path), "code")))
867
845
  return yield* new GraphMutationError({
868
846
  message: `Phase '${phase.id}' has a materialized worktree; remove it with Agency before renaming task '${id}'`,
@@ -952,10 +930,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
952
930
  })
953
931
  }
954
932
  const phase = yield* phases.show(taskId, id, root)
955
- if (phase.data.claim?.state === "active")
956
- return yield* new GraphMutationError({
957
- message: `Phase '${id}' has an active claim; release or finish it before renaming`,
958
- })
959
933
  const from = dirname(phase.path)
960
934
  const to = join(dirname(from), newId)
961
935
  if (yield* fs.exists(to))
@@ -230,7 +230,7 @@ describe("GraphService", () => {
230
230
  expect(calls).toBe(0)
231
231
  })
232
232
 
233
- test("never reports claimed or terminal execution units as ready", async () => {
233
+ test("never reports active or terminal execution units as ready", async () => {
234
234
  const root = await createWorkbase()
235
235
  roots.push(root)
236
236
  const path = "tasks/ship/phases/implement/PHASE.md"
@@ -177,7 +177,7 @@ describe("IntegrationService", () => {
177
177
  "agency task status <task> dropped --if-revision <revision> --json",
178
178
  "Continue already materialized work",
179
179
  "agency pr create <task> [phase]",
180
- "agency finish <task> [phase] --session-id <id>",
180
+ "agency task status <task> done --if-revision <revision> --no-pull-request",
181
181
  "agency task create <slug> --multi-phase",
182
182
  "agency task handoff <investigation-task> <new-task>",
183
183
  "agency review refresh <task> --if-revision <revision> --json",
@@ -260,6 +260,9 @@ describe("IntegrationService", () => {
260
260
  "!result.authority?.writable?.checkoutPath",
261
261
  )
262
262
  expect(managedWorkbaseOpencodePlugin).toContain('status !== "working"')
263
+ expect(managedWorkbaseOpencodePlugin).toContain(
264
+ 'output.env.AGENCY_INVOCATION_SOURCE = "agent"',
265
+ )
263
266
  expect(managedWorkbaseOpencodePlugin).toContain(
264
267
  "output.env.AGENCY_SESSION_ID = sessionID",
265
268
  )
@@ -626,7 +629,7 @@ describe("IntegrationService", () => {
626
629
  )
627
630
  expect(body).toContain("Never invent entity IDs")
628
631
  expect(body).toContain("Preserve parent backlinks")
629
- expect(body).toContain("dirty-worktree, active-claim, revision")
632
+ expect(body).toContain("dirty-worktree, revision")
630
633
  expect(body).toContain("`agency work` is the human launch flow")
631
634
  expect(body).toContain("Agency worker launch target: <target>.")
632
635
  expect(body).toContain("environment variables and a generated")
@@ -636,7 +639,7 @@ describe("IntegrationService", () => {
636
639
  )
637
640
  expect(body).toMatch(/If\s+the prompt\s+and context disagree/)
638
641
  expect(body).toContain("marks execution work")
639
- expect(body).toContain("without creating a claim")
642
+ expect(body).toContain("marks execution work")
640
643
  expect(body).toContain("formatting, type checks, build, dead-code checks")
641
644
  expect(body).toContain("Review and commit the diff")
642
645
  expect(body).toContain("Use `agency push`")
@@ -650,7 +653,7 @@ describe("IntegrationService", () => {
650
653
  expect(body).toContain("marking it ready")
651
654
  expect(body).toMatch(/completing\s+a refinement loop/)
652
655
  expect(body).toContain("pausing or handing off")
653
- expect(body).toContain("`agency finish`")
656
+ expect(body).toContain("update status")
654
657
  expect(body).toContain("`agency sync`")
655
658
  expect(body).toContain("`--no-pull-request --summary <text>`")
656
659
  expect(body).toContain("`TASK.md` or `PHASE.md`")
@@ -262,7 +262,11 @@ export const runLifecycleTransaction = ({
262
262
  })
263
263
  }
264
264
  } catch (cause) {
265
- if (cause instanceof LifecycleTransactionError) throw cause
265
+ if (
266
+ cause instanceof LifecycleTransactionError ||
267
+ cause instanceof RevisionConflictError
268
+ )
269
+ throw cause
266
270
  const rollbackErrors: unknown[] = []
267
271
  for (const step of [...completed].reverse()) {
268
272
  if (!step.rollback) continue
@@ -230,7 +230,6 @@ export class PhaseService extends Effect.Service<PhaseService>()(
230
230
  base: taskData.base,
231
231
  pr: taskData.pr,
232
232
  status: taskData.status,
233
- ...(taskData.claim ? { claim: taskData.claim } : {}),
234
233
  ...(taskData.completion
235
234
  ? { completion: taskData.completion }
236
235
  : {}),
@@ -515,16 +514,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
515
514
  const validStatus = yield* decodeStatus(status)
516
515
  if (validStatus === "delegated") {
517
516
  return yield* new PhaseError({
518
- message:
519
- "Delegation requires explicit ownership; use 'agency claim'",
517
+ message: "Delegated status cannot be set directly",
520
518
  })
521
519
  }
522
520
  const record = yield* service.show(taskId, id, startPath)
523
- if (record.data.claim?.state === "active") {
524
- return yield* new PhaseError({
525
- message: `Phase '${id}' has an active claim; use agency release or agency finish`,
526
- })
527
- }
528
521
  if (nonPrCompletion && validStatus !== "done") {
529
522
  return yield* new PhaseError({
530
523
  message: "Non-PR completion is valid only with a done status",