@markjaquith/agency 2.12.0 → 2.14.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
@@ -357,7 +357,7 @@ Inspect tasks:
357
357
  ```text
358
358
  agency task list [--json]
359
359
  agency task show <id> [--json]
360
- agency task status <id> <open|working|delegated|done|dropped> [--json]
360
+ agency task status <id> <open|done|dropped> [--json]
361
361
  ```
362
362
 
363
363
  To add a phase to an existing single-phase task, name the phase that will own
@@ -384,16 +384,48 @@ agency phase create <task-id> <phase-id>
384
384
 
385
385
  agency phase list <task-id> [--json]
386
386
  agency phase show <task-id> <phase-id> [--json]
387
- agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped> [--json]
387
+ agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
388
388
  ```
389
389
 
390
390
  Single-phase tasks and phases store status in YAML. New execution units start
391
391
  `open`, and `agency work` marks the selected execution unit `working` immediately
392
- before launch. Use the status subcommands to mark work `delegated`, `done`,
393
- `dropped`, or open it again. The interactive work selector displays status
394
- markers before execution units. Open, working, and delegated work may transition
395
- to any status. Done and dropped work are terminal and may only remain unchanged
396
- or transition to open; reopen terminal work before changing its outcome.
392
+ before launch. Use claims for coordinated ownership and the status subcommands
393
+ for manual lifecycle overrides. The interactive work selector displays status
394
+ markers before execution units. Existing working and delegated work may be
395
+ released to `open` or assigned a terminal outcome. Done and dropped work are
396
+ terminal and may only remain unchanged or transition to open; reopen terminal
397
+ work before changing its outcome.
398
+
399
+ `delegated` remains readable for existing workbases but cannot be newly assigned.
400
+ Delegation is now explicit: the claimant identifies the orchestrator and the
401
+ runner identifies the assigned agent.
402
+
403
+ ### Claims
404
+
405
+ Claim mutations require the SHA-256 revision exposed by `agency context` or
406
+ `agency graph`. Every operation compares that revision while holding an exclusive
407
+ document lock and atomically replaces the execution document.
408
+
409
+ ```text
410
+ agency claim <task-id> [phase-id] --claimant <id> --runner <id>
411
+ --session-id <id> --revision <sha256> [--expires-at <timestamp>] [--json]
412
+ agency release <task-id> [phase-id] --session-id <id>
413
+ --revision <sha256> [--json]
414
+ agency finish <task-id> [phase-id] --session-id <id>
415
+ --revision <sha256> --outcome <done|dropped> [--json]
416
+ ```
417
+
418
+ An active claim sets status to `working`. Release returns it to `open`; finish
419
+ sets the terminal outcome. Released and finished ownership metadata remains in
420
+ frontmatter. Conflicts return the current revision and complete ownership record
421
+ in the machine error envelope rather than overwriting it. Expired claims may be
422
+ replaced with a revision-guarded claim.
423
+
424
+ `agency work` claims an execution unit before launching its agent. Set
425
+ `AGENCY_CLAIMANT`, `AGENCY_RUNNER`, or `AGENCY_SESSION_ID` to supply orchestrator
426
+ identities; otherwise Agency derives them from the user, selected agent, and
427
+ process. The launched agent receives `AGENCY_SESSION_ID` and
428
+ `AGENCY_CLAIM_REVISION` for a later release or finish operation.
397
429
 
398
430
  ### Archive
399
431
 
@@ -412,6 +444,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
412
444
 
413
445
  ```text
414
446
  agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
447
+ agency work prepare [target] [--dry-run] [--json]
415
448
  agency pr create <task-id> [phase-id] [--draft] [--json]
416
449
  ```
417
450
 
@@ -421,6 +454,12 @@ workbase, Agency first presents the registered workbases, then the selected
421
454
  workbase's hierarchy. If `fzf` is not installed, Agency prints the available
422
455
  choices and asks for an explicit directory.
423
456
 
457
+ `agency work prepare` resolves an execution unit and creates or reuses its
458
+ writable and reference worktrees without launching an agent or changing status.
459
+ Its JSON result includes document and checkout paths, resolved commits, actions,
460
+ and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
461
+ changes without applying them.
462
+
424
463
  Epic and multi-phase task targets launch orchestration agents beside their
425
464
  documents. Single-phase tasks and phases fetch repositories, create or reuse
426
465
  worktrees under `code/`, and launch an execution agent in the writable checkout
package/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { parseCli } from "./src/cli-parser"
5
5
  import { init, help as initHelp } from "./src/commands/init"
6
6
  import { task, help as taskHelp } from "./src/commands/task"
7
7
  import { pr, help as prHelp } from "./src/commands/pr"
8
- import { work, help as workHelp } from "./src/commands/work"
8
+ import { work, workPrepare, help as workHelp } from "./src/commands/work"
9
9
  import { status, help as statusHelp } from "./src/commands/status"
10
10
  import { validate, help as validateHelp } from "./src/commands/validate"
11
11
  import { context, help as contextHelp } from "./src/commands/context"
@@ -32,6 +32,13 @@ import { ArchiveService } from "./src/services/ArchiveService"
32
32
  import { IntegrationService } from "./src/services/IntegrationService"
33
33
  import { ContextService } from "./src/services/ContextService"
34
34
  import { GraphService } from "./src/services/GraphService"
35
+ import { ClaimService } from "./src/services/ClaimService"
36
+ import {
37
+ claimCommand,
38
+ claimHelp,
39
+ releaseHelp,
40
+ finishHelp,
41
+ } from "./src/commands/claim"
35
42
  import {
36
43
  collectCommandResult,
37
44
  errorEnvelope,
@@ -53,6 +60,7 @@ const CliLayer = Layer.mergeAll(
53
60
  IntegrationService.Default,
54
61
  ContextService.Default,
55
62
  GraphService.Default,
63
+ ClaimService.Default,
56
64
  )
57
65
 
58
66
  /**
@@ -99,6 +107,61 @@ const VERSION = packageJson.version
99
107
 
100
108
  // Define commands
101
109
  const commands: Record<string, Command> = {
110
+ claim: {
111
+ run: async (args: string[], options: Record<string, any>) => {
112
+ if (options.help) return console.log(claimHelp)
113
+ await runCommand(
114
+ claimCommand({
115
+ operation: "claim",
116
+ taskId: args[0],
117
+ phaseId: args[1],
118
+ claimant: options.claimant,
119
+ runner: options.runner,
120
+ sessionId: options["session-id"],
121
+ revision: options.revision,
122
+ expiresAt: options["expires-at"],
123
+ json: options.json,
124
+ silent: options.silent,
125
+ verbose: options.verbose,
126
+ }),
127
+ )
128
+ },
129
+ },
130
+ release: {
131
+ run: async (args: string[], options: Record<string, any>) => {
132
+ if (options.help) return console.log(releaseHelp)
133
+ await runCommand(
134
+ claimCommand({
135
+ operation: "release",
136
+ taskId: args[0],
137
+ phaseId: args[1],
138
+ sessionId: options["session-id"],
139
+ revision: options.revision,
140
+ json: options.json,
141
+ silent: options.silent,
142
+ verbose: options.verbose,
143
+ }),
144
+ )
145
+ },
146
+ },
147
+ finish: {
148
+ run: async (args: string[], options: Record<string, any>) => {
149
+ if (options.help) return console.log(finishHelp)
150
+ await runCommand(
151
+ claimCommand({
152
+ operation: "finish",
153
+ taskId: args[0],
154
+ phaseId: args[1],
155
+ sessionId: options["session-id"],
156
+ revision: options.revision,
157
+ outcome: options.outcome,
158
+ json: options.json,
159
+ silent: options.silent,
160
+ verbose: options.verbose,
161
+ }),
162
+ )
163
+ },
164
+ },
102
165
  init: {
103
166
  run: async (args: string[], options: Record<string, any>) => {
104
167
  if (options.help) {
@@ -274,10 +337,13 @@ const commands: Record<string, Command> = {
274
337
  console.log(workHelp)
275
338
  return
276
339
  }
340
+ const preparing = args[0] === "prepare"
277
341
  await runCommand(
278
- work({
279
- directory: args[0],
342
+ (preparing ? workPrepare : work)({
343
+ directory: args[preparing ? 1 : 0],
280
344
  epicId: options.epic,
345
+ json: options.json,
346
+ dryRun: options["dry-run"],
281
347
  silent: options.silent,
282
348
  verbose: options.verbose,
283
349
  opencode: options.opencode,
@@ -372,6 +438,9 @@ Commands:
372
438
  integration <command> Inspect or sync managed integration files
373
439
  epic <subcommand> Manage epics
374
440
  phase <subcommand> Manage task phases
441
+ claim <task> [phase] Claim an execution unit
442
+ release <task> [phase] Release an execution unit
443
+ finish <task> [phase] Finish an execution unit
375
444
  archive <type> Archive a work item
376
445
  task <subcommand> Manage tasks
377
446
  work [directory|task] Work on an epic, task, or phase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -339,6 +339,7 @@
339
339
  "base": { "type": "string" },
340
340
  "pr": { "type": ["string", "null"] },
341
341
  "status": { "$ref": "#/$defs/status" },
342
+ "claim": { "$ref": "#/$defs/claim" },
342
343
  "sha256": { "type": "string" }
343
344
  }
344
345
  },
@@ -372,6 +373,7 @@
372
373
  "base": { "type": "string" },
373
374
  "pr": { "type": ["string", "null"] },
374
375
  "status": { "$ref": "#/$defs/status" },
376
+ "claim": { "$ref": "#/$defs/claim" },
375
377
  "sha256": { "type": "string" }
376
378
  }
377
379
  },
@@ -393,7 +395,32 @@
393
395
  "branch": { "type": "string" },
394
396
  "base": { "type": "string" },
395
397
  "pr": { "type": ["string", "null"] },
396
- "status": { "$ref": "#/$defs/status" }
398
+ "status": { "$ref": "#/$defs/status" },
399
+ "claim": { "$ref": "#/$defs/claim" }
400
+ }
401
+ },
402
+ "claim": {
403
+ "type": "object",
404
+ "additionalProperties": false,
405
+ "required": [
406
+ "claimant",
407
+ "runner",
408
+ "sessionId",
409
+ "startedAt",
410
+ "targetRevision",
411
+ "state"
412
+ ],
413
+ "properties": {
414
+ "claimant": { "type": "string", "minLength": 1 },
415
+ "runner": { "type": "string", "minLength": 1 },
416
+ "sessionId": { "type": "string", "minLength": 1 },
417
+ "startedAt": { "type": "string", "format": "date-time" },
418
+ "targetRevision": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
419
+ "expiresAt": { "type": "string", "format": "date-time" },
420
+ "state": { "enum": ["active", "released", "finished"] },
421
+ "releasedAt": { "type": "string", "format": "date-time" },
422
+ "finishedAt": { "type": "string", "format": "date-time" },
423
+ "outcome": { "enum": ["done", "dropped"] }
397
424
  }
398
425
  },
399
426
  "repositoryData": {
@@ -213,7 +213,9 @@ details explicitly with `--include bodies|workspace|git|pr`. For large workbases
213
213
  - Epic frontmatter owns the child-task dependency graph.
214
214
  - `pr` is either a GitHub PR URL string or `null`.
215
215
  - Execution-unit `status` is `open`, `working`, `delegated`, `done`, or `dropped`.
216
- New work starts open, and `agency work` marks it working before agent launch.
216
+ New work starts open, and an active claim sets it working before agent launch.
217
+ `delegated` is readable legacy state; distinct claimant and runner identities
218
+ now represent delegation.
217
219
  - Keep directory IDs stable; encode sequencing with `dependsOn`, not numeric
218
220
  directory prefixes.
219
221
  - Do not use YAML duplicate keys, anchors, aliases, or custom tags.
@@ -224,8 +226,17 @@ prose, preserve backlinks and run validation immediately afterward.
224
226
  Update execution status with:
225
227
 
226
228
  ```bash
227
- agency task status <task-id> <open|working|delegated|done|dropped>
228
- agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped>
229
+ agency task status <task-id> <open|done|dropped>
230
+ agency phase status <task-id> <phase-id> <open|done|dropped>
231
+ ```
232
+
233
+ Coordinate execution ownership with a document revision from `agency context`
234
+ or `agency graph`:
235
+
236
+ ```bash
237
+ agency claim <task-id> [phase-id] --claimant <id> --runner <id> --session-id <id> --revision <sha256>
238
+ agency release <task-id> [phase-id] --session-id <id> --revision <sha256>
239
+ agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
229
240
  ```
230
241
 
231
242
  ## Archive Completed Work
@@ -141,11 +141,92 @@ describe("strict CLI parsing", () => {
141
141
  [["validate", "one", "two"], "agency validate"],
142
142
  [["context", "one", "two"], "agency context"],
143
143
  [["graph", "extra"], "agency graph"],
144
+ [
145
+ [
146
+ "claim",
147
+ "one",
148
+ "two",
149
+ "three",
150
+ "--claimant",
151
+ "a",
152
+ "--runner",
153
+ "r",
154
+ "--session-id",
155
+ "s",
156
+ "--revision",
157
+ "0".repeat(64),
158
+ ],
159
+ "agency claim",
160
+ ],
161
+ [
162
+ [
163
+ "release",
164
+ "one",
165
+ "two",
166
+ "three",
167
+ "--session-id",
168
+ "s",
169
+ "--revision",
170
+ "0".repeat(64),
171
+ ],
172
+ "agency release",
173
+ ],
174
+ [
175
+ [
176
+ "finish",
177
+ "one",
178
+ "two",
179
+ "three",
180
+ "--session-id",
181
+ "s",
182
+ "--revision",
183
+ "0".repeat(64),
184
+ "--outcome",
185
+ "done",
186
+ ],
187
+ "agency finish",
188
+ ],
144
189
  ] as const) {
145
190
  expectUsageError([...args], usage)
146
191
  }
147
192
  })
148
193
 
194
+ test("validates revision-guarded claim operations", () => {
195
+ const revision = "0".repeat(64)
196
+ expect(
197
+ parseCli([
198
+ "claim",
199
+ "task",
200
+ "phase",
201
+ "--claimant",
202
+ "orchestrator",
203
+ "--runner",
204
+ "agent",
205
+ "--session-id",
206
+ "job-1",
207
+ "--revision",
208
+ revision,
209
+ "--expires-at",
210
+ "2026-07-17T13:00:00.000Z",
211
+ ]),
212
+ ).toMatchObject({ commandName: "claim", args: ["task", "phase"] })
213
+ expect(() =>
214
+ parseCli(["release", "task", "--session-id", "job-1"]),
215
+ ).toThrow("--revision' is required")
216
+ expect(() =>
217
+ parseCli([
218
+ "finish",
219
+ "task",
220
+ "--session-id",
221
+ "job-1",
222
+ "--revision",
223
+ revision,
224
+ "--outcome",
225
+ "working",
226
+ ]),
227
+ ).toThrow("must be 'done' or 'dropped'")
228
+ })
229
+
149
230
  test("accepts context projections and keeps compact command-local", () => {
150
231
  expect(parseCli(["context", ".", "--json", "--compact"])).toMatchObject({
151
232
  commandName: "context",
@@ -155,6 +236,22 @@ describe("strict CLI parsing", () => {
155
236
  expectUsageError(["status", "--compact"], "agency status")
156
237
  })
157
238
 
239
+ test("accepts work preparation options only for the prepare subcommand", () => {
240
+ expect(
241
+ parseCli(["work", "prepare", "tasks/example", "--dry-run", "--json"]),
242
+ ).toMatchObject({
243
+ commandName: "work",
244
+ args: ["prepare", "tasks/example"],
245
+ values: { "dry-run": true, json: true },
246
+ })
247
+ expect(() => parseCli(["work", "example", "--dry-run"])).toThrow(
248
+ "only valid with 'agency work prepare'",
249
+ )
250
+ expect(() => parseCli(["work", "prepare", "--opencode"])).toThrow(
251
+ "cannot be combined",
252
+ )
253
+ })
254
+
158
255
  test("accepts repeatable graph filters and rejects output conflicts", () => {
159
256
  expect(
160
257
  parseCli([
package/src/cli-parser.ts CHANGED
@@ -59,6 +59,21 @@ const phaseCreateOptions = {
59
59
  "first-phase": { type: "string" },
60
60
  } satisfies OptionConfig
61
61
 
62
+ const claimOptions = {
63
+ ...outputOptions,
64
+ claimant: { type: "string" },
65
+ runner: { type: "string" },
66
+ "session-id": { type: "string" },
67
+ revision: { type: "string" },
68
+ "expires-at": { type: "string" },
69
+ } satisfies OptionConfig
70
+
71
+ const ownedClaimOptions = {
72
+ ...outputOptions,
73
+ "session-id": { type: "string" },
74
+ revision: { type: "string" },
75
+ } satisfies OptionConfig
76
+
62
77
  const commands = {
63
78
  init: {
64
79
  usage: "agency init [path] [--json]",
@@ -258,6 +273,52 @@ const commands = {
258
273
  },
259
274
  },
260
275
  },
276
+ claim: {
277
+ usage: "agency claim <task-id> [phase-id] [options]",
278
+ options: claimOptions,
279
+ command: {
280
+ usage:
281
+ "agency claim <task-id> [phase-id] --claimant <id> --runner <id> --session-id <id> --revision <sha256> [--expires-at <timestamp>] [--json]",
282
+ minArgs: 1,
283
+ maxArgs: 2,
284
+ options: [
285
+ "claimant",
286
+ "runner",
287
+ "session-id",
288
+ "revision",
289
+ "expires-at",
290
+ "json",
291
+ ],
292
+ required: ["claimant", "runner", "session-id", "revision"],
293
+ },
294
+ },
295
+ release: {
296
+ usage: "agency release <task-id> [phase-id] [options]",
297
+ options: ownedClaimOptions,
298
+ command: {
299
+ usage:
300
+ "agency release <task-id> [phase-id] --session-id <id> --revision <sha256> [--json]",
301
+ minArgs: 1,
302
+ maxArgs: 2,
303
+ options: ["session-id", "revision", "json"],
304
+ required: ["session-id", "revision"],
305
+ },
306
+ },
307
+ finish: {
308
+ usage: "agency finish <task-id> [phase-id] [options]",
309
+ options: {
310
+ ...ownedClaimOptions,
311
+ outcome: { type: "string" },
312
+ },
313
+ command: {
314
+ usage:
315
+ "agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [--json]",
316
+ minArgs: 1,
317
+ maxArgs: 2,
318
+ options: ["session-id", "revision", "outcome", "json"],
319
+ required: ["session-id", "revision", "outcome"],
320
+ },
321
+ },
261
322
  archive: {
262
323
  usage: "agency archive <epic|task|phase>",
263
324
  options: outputOptions,
@@ -283,18 +344,22 @@ const commands = {
283
344
  },
284
345
  },
285
346
  work: {
286
- usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
347
+ usage:
348
+ "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
287
349
  options: {
288
350
  ...commonOptions,
351
+ json: { type: "boolean" },
352
+ "dry-run": { type: "boolean" },
289
353
  epic: { type: "string" },
290
354
  opencode: { type: "boolean" },
291
355
  claude: { type: "boolean" },
292
356
  },
293
357
  command: {
294
- usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
358
+ usage:
359
+ "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
295
360
  minArgs: 0,
296
- maxArgs: 1,
297
- options: ["epic", "opencode", "claude"],
361
+ maxArgs: 2,
362
+ options: ["json", "dry-run", "epic", "opencode", "claude"],
298
363
  conflicts: [
299
364
  ["opencode", "claude"],
300
365
  ["epic", "$positional"],
@@ -649,6 +714,37 @@ export function parseCli(args: readonly string[]): ParsedCli {
649
714
  if (commandName === "graph") {
650
715
  validateGraphOptions(parsed.values, spec)
651
716
  }
717
+ if (
718
+ commandName === "finish" &&
719
+ parsed.values.outcome !== "done" &&
720
+ parsed.values.outcome !== "dropped"
721
+ ) {
722
+ throw usageError(
723
+ "Option '--outcome' must be 'done' or 'dropped'.",
724
+ spec.usage,
725
+ )
726
+ }
727
+ if (commandName === "work") {
728
+ const preparing = commandPositionals[0] === "prepare"
729
+ if (
730
+ (!preparing && commandPositionals.length > 1) ||
731
+ (preparing &&
732
+ (parsed.values.epic || parsed.values.opencode || parsed.values.claude))
733
+ ) {
734
+ throw usageError(
735
+ preparing
736
+ ? "Work preparation cannot be combined with agent or epic options."
737
+ : "The work command accepts at most one target.",
738
+ spec.usage,
739
+ )
740
+ }
741
+ if (!preparing && (parsed.values.json || parsed.values["dry-run"])) {
742
+ throw usageError(
743
+ "Options '--json' and '--dry-run' are only valid with 'agency work prepare'.",
744
+ spec.usage,
745
+ )
746
+ }
747
+ }
652
748
 
653
749
  return {
654
750
  commandName: commandName as keyof typeof commands,