@markjaquith/agency 2.21.0 → 2.23.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
@@ -400,6 +400,8 @@ repository. Alias names are then used by all documents and commands.
400
400
  Commands that print Agency-owned results accept `--json`, including initialization,
401
401
  integration inspection/sync, repository mutations, entity creation/list/show,
402
402
  status, validation, graph export, reconciliation, and PR creation.
403
+ Entity create, list, and show results include a stable SHA-256 `revision` of the
404
+ complete Markdown document.
403
405
 
404
406
  ### Epics
405
407
 
@@ -515,6 +517,13 @@ references as one rollback-capable mutation and refuse when a materialized
515
517
  worktree would make the directory move unsafe. Mutation JSON includes changed
516
518
  paths and the focused validation scope.
517
519
 
520
+ Epic, task, and phase update, rename, move, and dependency mutations accept
521
+ `--if-revision <hash>`. The option is optional for interactive human use. When
522
+ provided, Agency fails with a structured `REVISION_CONFLICT` containing the
523
+ expected and current revisions if the target changed. Multi-document mutations
524
+ also recheck every affected document after taking the mutation lock and before
525
+ writing anything.
526
+
518
527
  Single-phase tasks and phases store status in YAML. New execution units start
519
528
  `open`, and `agency work` marks the selected execution unit `working` immediately
520
529
  before launch. Use claims for coordinated ownership and the status subcommands
package/cli.ts CHANGED
@@ -238,6 +238,7 @@ const commands: Record<string, Command> = {
238
238
  ticketUrl: options["ticket-url"],
239
239
  description: options.description,
240
240
  clearDescription: options["clear-description"],
241
+ ifRevision: options["if-revision"],
241
242
  repos: options.repo,
242
243
  json: options.json,
243
244
  statuses: options.status,
@@ -289,6 +290,7 @@ const commands: Record<string, Command> = {
289
290
  clearReferences: options["clear-references"],
290
291
  prUrl: options["pr-url"],
291
292
  clearPr: options["clear-pr"],
293
+ ifRevision: options["if-revision"],
292
294
  dependsOn: options["depends-on"],
293
295
  firstPhase: options["first-phase"],
294
296
  json: options.json,
@@ -399,6 +401,7 @@ const commands: Record<string, Command> = {
399
401
  clearReferences: options["clear-references"],
400
402
  prUrl: options["pr-url"],
401
403
  clearPr: options["clear-pr"],
404
+ ifRevision: options["if-revision"],
402
405
  noEpic: options["no-epic"],
403
406
  multiPhase: options["multi-phase"],
404
407
  json: options.json,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -296,6 +296,31 @@ describe("strict CLI parsing", () => {
296
296
  ).toThrow("must be 'done' or 'dropped'")
297
297
  })
298
298
 
299
+ test("accepts valid mutation revisions and rejects malformed hashes", () => {
300
+ const revision = "a".repeat(64)
301
+ expect(
302
+ parseCli([
303
+ "task",
304
+ "move",
305
+ "example",
306
+ "--no-epic",
307
+ "--if-revision",
308
+ revision,
309
+ ]),
310
+ ).toMatchObject({ values: { "if-revision": revision } })
311
+ expect(() =>
312
+ parseCli([
313
+ "phase",
314
+ "rename",
315
+ "task",
316
+ "phase",
317
+ "renamed",
318
+ "--if-revision",
319
+ "stale",
320
+ ]),
321
+ ).toThrow("64-character SHA-256 hash")
322
+ })
323
+
299
324
  test("accepts context projections and keeps compact command-local", () => {
300
325
  expect(parseCli(["context", ".", "--json", "--compact"])).toMatchObject({
301
326
  commandName: "context",
package/src/cli-parser.ts CHANGED
@@ -91,6 +91,7 @@ const phaseCreateOptions = {
91
91
  } satisfies OptionConfig
92
92
 
93
93
  const mutationOptions = {
94
+ "if-revision": { type: "string" },
94
95
  "clear-description": { type: "boolean" },
95
96
  "clear-ticket": { type: "boolean" },
96
97
  "clear-references": { type: "boolean" },
@@ -249,6 +250,7 @@ const commands = {
249
250
  "clear-description",
250
251
  "ticket-url",
251
252
  "repo",
253
+ "if-revision",
252
254
  "json",
253
255
  ],
254
256
  repeatable: ["repo"],
@@ -258,7 +260,7 @@ const commands = {
258
260
  usage: "agency epic rename <id> <new-id> [--json]",
259
261
  minArgs: 2,
260
262
  maxArgs: 2,
261
- options: ["json"],
263
+ options: ["if-revision", "json"],
262
264
  },
263
265
  },
264
266
  },
@@ -344,6 +346,7 @@ const commands = {
344
346
  "base",
345
347
  "pr-url",
346
348
  "clear-pr",
349
+ "if-revision",
347
350
  "json",
348
351
  ],
349
352
  repeatable: ["reference"],
@@ -358,13 +361,13 @@ const commands = {
358
361
  usage: "agency task rename <id> <new-id> [--json]",
359
362
  minArgs: 2,
360
363
  maxArgs: 2,
361
- options: ["json"],
364
+ options: ["if-revision", "json"],
362
365
  },
363
366
  move: {
364
367
  usage: "agency task move <id> (--epic <id> | --no-epic) [--json]",
365
368
  minArgs: 1,
366
369
  maxArgs: 1,
367
- options: ["epic", "no-epic", "json"],
370
+ options: ["epic", "no-epic", "if-revision", "json"],
368
371
  conflicts: [["epic", "no-epic"]],
369
372
  },
370
373
  dependency: {
@@ -372,7 +375,7 @@ const commands = {
372
375
  "agency task dependency <add|remove> <task-id> <dependency-id> [--json]",
373
376
  minArgs: 3,
374
377
  maxArgs: 3,
375
- options: ["json"],
378
+ options: ["if-revision", "json"],
376
379
  },
377
380
  },
378
381
  },
@@ -441,6 +444,7 @@ const commands = {
441
444
  "base",
442
445
  "pr-url",
443
446
  "clear-pr",
447
+ "if-revision",
444
448
  "json",
445
449
  ],
446
450
  repeatable: ["reference"],
@@ -454,14 +458,14 @@ const commands = {
454
458
  usage: "agency phase rename <task-id> <phase-id> <new-id> [--json]",
455
459
  minArgs: 3,
456
460
  maxArgs: 3,
457
- options: ["json"],
461
+ options: ["if-revision", "json"],
458
462
  },
459
463
  dependency: {
460
464
  usage:
461
465
  "agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id> [--json]",
462
466
  minArgs: 4,
463
467
  maxArgs: 4,
464
- options: ["json"],
468
+ options: ["if-revision", "json"],
465
469
  },
466
470
  },
467
471
  },
@@ -1125,11 +1129,22 @@ export function parseCli(args: readonly string[]): ParsedCli {
1125
1129
  ["epic", "task", "phase"].includes(commandName) &&
1126
1130
  subcommand === "update"
1127
1131
  ) {
1128
- const mutationNames = (spec.options ?? []).filter((name) => name !== "json")
1132
+ const mutationNames = (spec.options ?? []).filter(
1133
+ (name) => name !== "json" && name !== "if-revision",
1134
+ )
1129
1135
  if (!mutationNames.some((name) => parsed.values[name] !== undefined)) {
1130
1136
  throw usageError("At least one update option is required.", spec.usage)
1131
1137
  }
1132
1138
  }
1139
+ if (
1140
+ typeof parsed.values["if-revision"] === "string" &&
1141
+ !/^[a-f0-9]{64}$/.test(parsed.values["if-revision"])
1142
+ ) {
1143
+ throw usageError(
1144
+ "Option '--if-revision' must be a 64-character SHA-256 hash.",
1145
+ spec.usage,
1146
+ )
1147
+ }
1133
1148
  if (commandName === "graph") {
1134
1149
  validateGraphOptions(parsed.values, spec)
1135
1150
  }
package/src/cli.test.ts CHANGED
@@ -203,6 +203,41 @@ describe("CLI", () => {
203
203
  ),
204
204
  )
205
205
  }
206
+ const observed = parseJson(
207
+ await runCli(["task", "show", "second", "--json"], root),
208
+ )
209
+ const taskPath = join(root, "tasks/second/TASK.md")
210
+ await Bun.write(
211
+ taskPath,
212
+ `${await Bun.file(taskPath).text()}\nConcurrent edit.\n`,
213
+ )
214
+ const conflict = await runCli(
215
+ [
216
+ "task",
217
+ "update",
218
+ "second",
219
+ "--description",
220
+ "Stale update",
221
+ "--if-revision",
222
+ observed.revision,
223
+ "--json",
224
+ ],
225
+ root,
226
+ )
227
+ expect(conflict.exitCode).toBe(1)
228
+ expect(JSON.parse(conflict.stdout)).toMatchObject({
229
+ ok: false,
230
+ error: {
231
+ code: "REVISION_CONFLICT",
232
+ retryable: true,
233
+ fields: {
234
+ path: "tasks/second/TASK.md",
235
+ expectedRevision: observed.revision,
236
+ currentRevision: expect.stringMatching(/^[a-f0-9]{64}$/),
237
+ },
238
+ },
239
+ })
240
+ expect(await Bun.file(taskPath).text()).toContain("Concurrent edit.")
206
241
 
207
242
  const updated = parseJson(
208
243
  await runCli(
@@ -40,9 +40,12 @@ describe("epic command", () => {
40
40
  ),
41
41
  )
42
42
 
43
- expect(JSON.parse(logs[0]!)).toEqual({
43
+ const created = JSON.parse(logs[0]!)
44
+ expect(created.revision).toMatch(/^[a-f0-9]{64}$/)
45
+ expect(created).toEqual({
44
46
  id: "example",
45
47
  path: join(root, "epics/example/EPIC.md"),
48
+ revision: created.revision,
46
49
  data: {
47
50
  ticketUrl: "https://example.com/epic",
48
51
  repos: [{ repo: "agency", ref: "main" }],
@@ -68,10 +71,13 @@ describe("epic command", () => {
68
71
  epic({ subcommand: "list", args: [], cwd: root, json: true }),
69
72
  ),
70
73
  )
71
- expect(JSON.parse(listLogs[0]!)).toEqual([
74
+ const listed = JSON.parse(listLogs[0]!)
75
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
76
+ expect(listed).toEqual([
72
77
  {
73
78
  id: "example",
74
79
  path: join(root, "epics/example/EPIC.md"),
80
+ revision: listed[0].revision,
75
81
  data: {
76
82
  ticketUrl: "https://example.com/epic",
77
83
  repos: [{ repo: "agency", ref: "main" }],
@@ -90,9 +96,12 @@ describe("epic command", () => {
90
96
  }),
91
97
  ),
92
98
  )
93
- expect(JSON.parse(showLogs[0]!)).toEqual({
99
+ const shown = JSON.parse(showLogs[0]!)
100
+ expect(shown.revision).toBe(listed[0].revision)
101
+ expect(shown).toEqual({
94
102
  id: "example",
95
103
  path: join(root, "epics/example/EPIC.md"),
104
+ revision: shown.revision,
96
105
  data: {
97
106
  ticketUrl: "https://example.com/epic",
98
107
  repos: [{ repo: "agency", ref: "main" }],
@@ -13,6 +13,7 @@ interface EpicOptions extends BaseCommandOptions {
13
13
  readonly ticketUrl?: string
14
14
  readonly description?: string
15
15
  readonly clearDescription?: boolean
16
+ readonly ifRevision?: string
16
17
  readonly repos?: readonly string[]
17
18
  readonly json?: boolean
18
19
  readonly statuses?: readonly string[]
@@ -122,6 +123,7 @@ export const epic = (options: EpicOptions) =>
122
123
  : parseRepositoryReferences(options.repos),
123
124
  },
124
125
  cwd,
126
+ options.ifRevision,
125
127
  )
126
128
  log(
127
129
  options.json
@@ -138,7 +140,12 @@ export const epic = (options: EpicOptions) =>
138
140
  new Error("Epic ID and new ID are required"),
139
141
  )
140
142
  }
141
- const output = yield* mutations.renameEpic(id, newId, cwd)
143
+ const output = yield* mutations.renameEpic(
144
+ id,
145
+ newId,
146
+ cwd,
147
+ options.ifRevision,
148
+ )
142
149
  log(
143
150
  options.json
144
151
  ? JSON.stringify(output, null, 2)
@@ -177,6 +184,9 @@ Update options:
177
184
  --clear-description Remove the description
178
185
  --repo <alias>:<ref> Replace repository references; repeatable
179
186
 
187
+ Mutation options:
188
+ --if-revision <hash> Require the target's current revision
189
+
180
190
  Options:
181
191
  --json Output results as JSON
182
192
  --status <status> Filter list by status; repeatable
@@ -19,6 +19,7 @@ interface PhaseOptions extends BaseCommandOptions {
19
19
  readonly clearReferences?: boolean
20
20
  readonly prUrl?: string
21
21
  readonly clearPr?: boolean
22
+ readonly ifRevision?: string
22
23
  readonly dependsOn?: readonly string[]
23
24
  readonly firstPhase?: string
24
25
  readonly json?: boolean
@@ -181,6 +182,7 @@ export const phase = (options: PhaseOptions) =>
181
182
  pr: options.clearPr ? null : options.prUrl,
182
183
  },
183
184
  cwd,
185
+ options.ifRevision,
184
186
  )
185
187
  log(
186
188
  options.json
@@ -196,7 +198,13 @@ export const phase = (options: PhaseOptions) =>
196
198
  new Error("Task ID, phase ID, and new ID are required"),
197
199
  )
198
200
  }
199
- const output = yield* mutations.renamePhase(taskId, phaseId, newId, cwd)
201
+ const output = yield* mutations.renamePhase(
202
+ taskId,
203
+ phaseId,
204
+ newId,
205
+ cwd,
206
+ options.ifRevision,
207
+ )
200
208
  log(
201
209
  options.json
202
210
  ? JSON.stringify(output, null, 2)
@@ -225,6 +233,7 @@ export const phase = (options: PhaseOptions) =>
225
233
  dependencyPhaseId,
226
234
  dependencyId,
227
235
  cwd,
236
+ options.ifRevision,
228
237
  )
229
238
  log(
230
239
  options.json
@@ -257,6 +266,9 @@ Subcommands:
257
266
  dependency <operation> <task> <phase> <dependency>
258
267
  Add or remove a phase dependency
259
268
 
269
+ Mutation option:
270
+ --if-revision <hash> Require the target's current revision
271
+
260
272
  Create options:
261
273
  --description <text> Short description of the phase
262
274
  --repo <alias> Writable repository
@@ -33,8 +33,8 @@ export const pr = (options: PrOptions) =>
33
33
  export const help = `
34
34
  Usage: agency pr create <task-id> [phase-id]
35
35
 
36
- Push the execution branch, create a GitHub pull request, and update its
37
- task or phase document.
36
+ Push the execution branch, create a pull request with the delivery provider,
37
+ and update its task or phase document.
38
38
 
39
39
  Options:
40
40
  --draft Create a draft pull request
@@ -48,10 +48,13 @@ describe("task and phase command JSON output", () => {
48
48
  task({ subcommand: "list", args: [], cwd: root, json: true }),
49
49
  ),
50
50
  )
51
- expect(JSON.parse(listLogs[0]!)).toEqual([
51
+ const listed = JSON.parse(listLogs[0]!)
52
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
53
+ expect(listed).toEqual([
52
54
  {
53
55
  id: "multi",
54
56
  path: join(root, "tasks/multi/TASK.md"),
57
+ revision: listed[0].revision,
55
58
  data: {
56
59
  ticketUrl: "https://example.com/task",
57
60
  phases: [{ id: "first" }],
@@ -69,9 +72,12 @@ describe("task and phase command JSON output", () => {
69
72
  }),
70
73
  ),
71
74
  )
72
- expect(JSON.parse(showLogs[0]!)).toEqual({
75
+ const shown = JSON.parse(showLogs[0]!)
76
+ expect(shown.revision).toBe(listed[0].revision)
77
+ expect(shown).toEqual({
73
78
  id: "multi",
74
79
  path: join(root, "tasks/multi/TASK.md"),
80
+ revision: shown.revision,
75
81
  data: {
76
82
  ticketUrl: "https://example.com/task",
77
83
  phases: [{ id: "first" }],
@@ -85,11 +91,14 @@ describe("task and phase command JSON output", () => {
85
91
  phase({ subcommand: "list", args: ["multi"], cwd: root, json: true }),
86
92
  ),
87
93
  )
88
- expect(JSON.parse(listLogs[0]!)).toEqual([
94
+ const listed = JSON.parse(listLogs[0]!)
95
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
96
+ expect(listed).toEqual([
89
97
  {
90
98
  taskId: "multi",
91
99
  id: "first",
92
100
  path: join(root, "tasks/multi/phases/first/PHASE.md"),
101
+ revision: listed[0].revision,
93
102
  data: {
94
103
  repo: "agency",
95
104
  branch: "task/first",
@@ -110,10 +119,13 @@ describe("task and phase command JSON output", () => {
110
119
  }),
111
120
  ),
112
121
  )
113
- expect(JSON.parse(showLogs[0]!)).toEqual({
122
+ const shown = JSON.parse(showLogs[0]!)
123
+ expect(shown.revision).toBe(listed[0].revision)
124
+ expect(shown).toEqual({
114
125
  taskId: "multi",
115
126
  id: "first",
116
127
  path: join(root, "tasks/multi/phases/first/PHASE.md"),
128
+ revision: shown.revision,
117
129
  data: {
118
130
  repo: "agency",
119
131
  branch: "task/first",
@@ -27,6 +27,7 @@ interface TaskOptions extends BaseCommandOptions {
27
27
  readonly clearReferences?: boolean
28
28
  readonly prUrl?: string
29
29
  readonly clearPr?: boolean
30
+ readonly ifRevision?: string
30
31
  readonly noEpic?: boolean
31
32
  readonly multiPhase?: boolean
32
33
  readonly json?: boolean
@@ -318,6 +319,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
318
319
  pr: options.clearPr ? null : options.prUrl,
319
320
  },
320
321
  cwd,
322
+ options.ifRevision,
321
323
  )
322
324
  log(
323
325
  options.json
@@ -333,7 +335,12 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
333
335
  new Error("Task ID and new ID are required"),
334
336
  )
335
337
  }
336
- const output = yield* mutations.renameTask(id, newId, cwd)
338
+ const output = yield* mutations.renameTask(
339
+ id,
340
+ newId,
341
+ cwd,
342
+ options.ifRevision,
343
+ )
337
344
  log(
338
345
  options.json
339
346
  ? JSON.stringify(output, null, 2)
@@ -348,6 +355,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
348
355
  id,
349
356
  options.noEpic ? null : (options.epic ?? null),
350
357
  cwd,
358
+ options.ifRevision,
351
359
  )
352
360
  log(
353
361
  options.json
@@ -376,6 +384,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
376
384
  id,
377
385
  dependencyId,
378
386
  cwd,
387
+ options.ifRevision,
379
388
  )
380
389
  log(
381
390
  options.json
@@ -408,6 +417,9 @@ Subcommands:
408
417
  dependency <operation> <task> <dependency>
409
418
  Add or remove a task dependency
410
419
 
420
+ Mutation option:
421
+ --if-revision <hash> Require the target's current revision
422
+
411
423
  Create options:
412
424
  --ticket-url <url> External ticket URL (optional)
413
425
  --description <text> Short description of the task
@@ -2,6 +2,7 @@ import { Schema } from "@effect/schema"
2
2
  import {
3
3
  EpicFrontmatter,
4
4
  PhaseFrontmatter,
5
+ PullRequestRecord,
5
6
  TaskFrontmatter,
6
7
  WorkStatus,
7
8
  } from "./workbase/schemas"
@@ -109,16 +110,7 @@ export const GraphExecutionGit = Schema.Struct({
109
110
  export const GraphPr = Schema.Union(
110
111
  Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
111
112
  Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
112
- Schema.Struct({
113
- recordedUrl: Schema.String,
114
- number: Schema.Number,
115
- state: Schema.String,
116
- title: Schema.String,
117
- isDraft: Schema.Boolean,
118
- headRefName: Schema.String,
119
- baseRefName: Schema.String,
120
- url: Schema.String,
121
- }),
113
+ PullRequestRecord,
122
114
  )
123
115
 
124
116
  const NodeIdentity = {
@@ -108,5 +108,24 @@ describe("machine protocol", () => {
108
108
  },
109
109
  },
110
110
  })
111
+ expect(
112
+ errorEnvelope({
113
+ _tag: "RevisionConflictError",
114
+ message: "revision conflict",
115
+ path: "tasks/example/TASK.md",
116
+ expectedRevision: "a".repeat(64),
117
+ currentRevision: "b".repeat(64),
118
+ }),
119
+ ).toMatchObject({
120
+ error: {
121
+ code: "REVISION_CONFLICT",
122
+ retryable: true,
123
+ fields: {
124
+ path: "tasks/example/TASK.md",
125
+ expectedRevision: "a".repeat(64),
126
+ currentRevision: "b".repeat(64),
127
+ },
128
+ },
129
+ })
111
130
  })
112
131
  })