@markjaquith/agency 2.22.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.22.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
@@ -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
@@ -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
  })
@@ -9,12 +9,16 @@ import {
9
9
  unlink,
10
10
  writeFile,
11
11
  } from "node:fs/promises"
12
- import { basename, dirname, join } from "node:path"
12
+ import { basename, dirname, join, relative } from "node:path"
13
13
  import { PhaseService } from "./PhaseService"
14
14
  import { TaskService } from "./TaskService"
15
15
  import { WorkbaseService } from "./WorkbaseService"
16
16
  import { FileSystemService } from "./FileSystemService"
17
- import { documentRevision } from "../workbase/document-revision"
17
+ import {
18
+ documentRevision,
19
+ isDocumentRevision,
20
+ RevisionConflictError,
21
+ } from "../workbase/document-revision"
18
22
  import {
19
23
  formatMarkdownDocument,
20
24
  parseFrontmatterSync,
@@ -33,14 +37,6 @@ class ClaimError extends Data.TaggedError("ClaimError")<{
33
37
  readonly target?: string
34
38
  }> {}
35
39
 
36
- class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
37
- readonly message: string
38
- readonly target: string
39
- readonly expectedRevision: string
40
- readonly actualRevision: string
41
- readonly claim?: ClaimRecord
42
- }> {}
43
-
44
40
  class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
45
41
  readonly message: string
46
42
  readonly target: string
@@ -59,6 +55,7 @@ class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
59
55
 
60
56
  interface ClaimTarget {
61
57
  readonly kind: "task" | "phase"
58
+ readonly root: string
62
59
  readonly taskId: string
63
60
  readonly phaseId?: string
64
61
  readonly path: string
@@ -149,7 +146,7 @@ const decodeExecution = (target: ClaimTarget, input: unknown) => {
149
146
  }
150
147
 
151
148
  const assertRevision = (revision: string) => {
152
- if (!/^[a-f0-9]{64}$/.test(revision)) {
149
+ if (!isDocumentRevision(revision)) {
153
150
  throw new ClaimError({
154
151
  message: "Revision must be a 64-character SHA-256 hash",
155
152
  })
@@ -169,8 +166,7 @@ const isUnexpired = (claim: ClaimRecord, now: Date) =>
169
166
  claim.state === "active" &&
170
167
  (claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
171
168
 
172
- const acquireLock = async (path: string) => {
173
- const lockPath = `${path}.claim.lock`
169
+ const acquireLock = async (lockPath: string, label: string) => {
174
170
  for (let attempt = 0; attempt < 1_750; attempt += 1) {
175
171
  try {
176
172
  const handle = await open(lockPath, "wx")
@@ -192,7 +188,7 @@ const acquireLock = async (path: string) => {
192
188
  await Bun.sleep(20)
193
189
  }
194
190
  }
195
- throw new ClaimError({ message: `Timed out waiting to update ${path}` })
191
+ throw new ClaimError({ message: `Timed out waiting to update ${label}` })
196
192
  }
197
193
 
198
194
  const updateAtomically = async <T>(
@@ -207,18 +203,24 @@ const updateAtomically = async <T>(
207
203
  now: Date,
208
204
  ) => {
209
205
  assertRevision(expectedRevision)
210
- const { handle, lockPath } = await acquireLock(target.path)
206
+ const graphLock = await acquireLock(
207
+ join(target.root, ".agency-graph-mutation.lock"),
208
+ target.root,
209
+ )
210
+ let documentLock: Awaited<ReturnType<typeof acquireLock>> | undefined
211
211
  let temporaryPath: string | undefined
212
212
  try {
213
+ documentLock = await acquireLock(`${target.path}.claim.lock`, target.path)
213
214
  const content = await readFile(target.path, "utf8")
214
- const actualRevision = documentRevision(content)
215
+ const currentRevision = documentRevision(content)
215
216
  const parsed = parseFrontmatterSync(content, target.path)
216
217
  const current = decodeExecution(target, parsed.data)
217
- if (actualRevision !== expectedRevision) {
218
+ if (currentRevision !== expectedRevision) {
218
219
  throw new RevisionConflictError({
220
+ path: relative(target.root, target.path),
219
221
  target: target.label,
220
222
  expectedRevision,
221
- actualRevision,
223
+ currentRevision,
222
224
  claim: current.claim,
223
225
  message: `Revision conflict for ${target.label}`,
224
226
  })
@@ -235,13 +237,15 @@ const updateAtomically = async <T>(
235
237
  return {
236
238
  ...result,
237
239
  target: target.label,
238
- previousRevision: actualRevision,
240
+ previousRevision: currentRevision,
239
241
  revision: documentRevision(updatedContent),
240
242
  }
241
243
  } finally {
242
244
  if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
243
- await handle.close().catch(() => undefined)
244
- await unlink(lockPath).catch(() => undefined)
245
+ await documentLock?.handle.close().catch(() => undefined)
246
+ if (documentLock) await unlink(documentLock.lockPath).catch(() => undefined)
247
+ await graphLock.handle.close().catch(() => undefined)
248
+ await unlink(graphLock.lockPath).catch(() => undefined)
245
249
  }
246
250
  }
247
251
 
@@ -278,6 +282,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
278
282
  const target: ClaimTarget = phaseId
279
283
  ? {
280
284
  kind: "phase",
285
+ root,
281
286
  taskId: task.id,
282
287
  phaseId,
283
288
  path: phase!.path,
@@ -285,6 +290,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
285
290
  }
286
291
  : {
287
292
  kind: "task",
293
+ root,
288
294
  taskId: task.id,
289
295
  path: task.path,
290
296
  label: `task '${task.id}'`,
@@ -7,6 +7,7 @@ import { normalizePullRequestRecord } from "../workbase/delivery-command"
7
7
  import { RepositoryService } from "./RepositoryService"
8
8
  import { aggregateProgress, readinessState } from "../readiness"
9
9
  import { parseFrontmatter } from "../workbase/frontmatter"
10
+ import { documentRevision } from "../workbase/document-revision"
10
11
  import {
11
12
  EpicFrontmatter,
12
13
  PhaseFrontmatter,
@@ -78,9 +79,6 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
78
79
  : { ok: true as const, value: result.right }
79
80
  }
80
81
 
81
- const hash = (content: string) =>
82
- new Bun.CryptoHasher("sha256").update(content).digest("hex")
83
-
84
82
  const isWithin = (root: string, path: string) => {
85
83
  const child = relative(root, path)
86
84
  return child === "" || (!child.startsWith(`..${sep}`) && child !== "..")
@@ -205,7 +203,7 @@ export class ContextService extends Effect.Service<ContextService>()(
205
203
  return {
206
204
  id,
207
205
  path,
208
- sha256: hash(content),
206
+ sha256: documentRevision(content),
209
207
  data: decoded.value,
210
208
  body: parsed.body,
211
209
  } satisfies Document<Schema.Schema.Type<S>>
@@ -279,7 +277,7 @@ export class ContextService extends Effect.Service<ContextService>()(
279
277
  taskDocuments.set(entry.name, {
280
278
  id: entry.name,
281
279
  path,
282
- sha256: hash(content),
280
+ sha256: documentRevision(content),
283
281
  data: decoded.value,
284
282
  body: parsed.right.body,
285
283
  })
@@ -303,7 +301,7 @@ export class ContextService extends Effect.Service<ContextService>()(
303
301
  phaseDocuments.set(`${entry.name}/${phaseEntry.name}`, {
304
302
  id: phaseEntry.name,
305
303
  path: phasePath,
306
- sha256: hash(phaseContent),
304
+ sha256: documentRevision(phaseContent),
307
305
  data: phaseDecoded.value,
308
306
  body: phaseParsed.right.body,
309
307
  })
@@ -12,6 +12,7 @@ import {
12
12
  formatMarkdownDocument,
13
13
  parseFrontmatter,
14
14
  } from "../workbase/frontmatter"
15
+ import { documentRevision } from "../workbase/document-revision"
15
16
 
16
17
  class EpicError extends Data.TaggedError("EpicError")<{
17
18
  readonly message: string
@@ -21,6 +22,7 @@ export interface EpicRecord {
21
22
  readonly id: string
22
23
  readonly path: string
23
24
  readonly content: string
25
+ readonly revision: string
24
26
  readonly data: Schema.Schema.Type<typeof EpicFrontmatter>
25
27
  }
26
28
 
@@ -90,7 +92,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
90
92
  `# ${title}\n\nDescribe the epic outcome.`,
91
93
  )
92
94
  yield* fs.writeFile(path, content)
93
- return { id: validId, path, content, data } satisfies EpicRecord
95
+ return {
96
+ id: validId,
97
+ path,
98
+ content,
99
+ revision: documentRevision(content),
100
+ data,
101
+ } satisfies EpicRecord
94
102
  }),
95
103
 
96
104
  list: (startPath: string = process.cwd()) =>
@@ -113,7 +121,13 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
113
121
  const content = yield* fs.readFile(path)
114
122
  const parsed = yield* parseFrontmatter(content, path)
115
123
  const data = yield* decodeEpic(parsed.data)
116
- records.push({ id: entry.name, path, content, data })
124
+ records.push({
125
+ id: entry.name,
126
+ path,
127
+ content,
128
+ revision: documentRevision(content),
129
+ data,
130
+ })
117
131
  }
118
132
  return records
119
133
  }),
@@ -280,6 +280,37 @@ describe("GraphMutationService", () => {
280
280
  )
281
281
  })
282
282
 
283
+ test("rejects stale guarded moves before changing any document", async () => {
284
+ const paths = [
285
+ join(root, "tasks/alpha/TASK.md"),
286
+ join(root, "epics/first-epic/EPIC.md"),
287
+ join(root, "epics/second-epic/EPIC.md"),
288
+ ]
289
+ const before = await Promise.all(paths.map((path) => Bun.file(path).text()))
290
+ let conflict: unknown
291
+ try {
292
+ await runTestEffect(
293
+ Effect.gen(function* () {
294
+ return yield* (yield* GraphMutationService).moveTask(
295
+ "alpha",
296
+ "second-epic",
297
+ root,
298
+ "0".repeat(64),
299
+ )
300
+ }),
301
+ )
302
+ } catch (error) {
303
+ conflict = error
304
+ }
305
+
306
+ expect(String(conflict)).toContain(
307
+ "Revision conflict for tasks/alpha/TASK.md",
308
+ )
309
+ expect(
310
+ await Promise.all(paths.map((path) => Bun.file(path).text())),
311
+ ).toEqual(before)
312
+ })
313
+
283
314
  test("refuses a rename that would invalidate a materialized worktree", async () => {
284
315
  await mkdir(join(root, "tasks/alpha/code/agency"), { recursive: true })
285
316
  await expect(
@@ -2,9 +2,9 @@ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { open, rename, rm } from "node:fs/promises"
4
4
  import { dirname, join, relative } from "node:path"
5
- import { EpicService } from "./EpicService"
5
+ import { EpicService, type EpicRecord } from "./EpicService"
6
6
  import { FileSystemService } from "./FileSystemService"
7
- import { PhaseService } from "./PhaseService"
7
+ import { PhaseService, type PhaseRecord } from "./PhaseService"
8
8
  import { TaskService } from "./TaskService"
9
9
  import { WorkbaseService } from "./WorkbaseService"
10
10
  import {
@@ -23,6 +23,10 @@ import {
23
23
  parseFrontmatter,
24
24
  } from "../workbase/frontmatter"
25
25
  import { validateDependencies } from "../workbase/dependency-graph"
26
+ import {
27
+ documentRevision,
28
+ RevisionConflictError,
29
+ } from "../workbase/document-revision"
26
30
 
27
31
  class GraphMutationError extends Data.TaggedError("GraphMutationError")<{
28
32
  readonly message: string
@@ -47,6 +51,10 @@ interface MutationResult {
47
51
 
48
52
  interface WritePlan {
49
53
  readonly root: string
54
+ readonly preconditions: readonly {
55
+ readonly path: string
56
+ readonly revision: string
57
+ }[]
50
58
  readonly writes: readonly {
51
59
  readonly path: string
52
60
  readonly content: string
@@ -114,7 +122,7 @@ const exists = async (path: string) => {
114
122
  }
115
123
  }
116
124
 
117
- const applyWritePlan = ({ root, writes, move }: WritePlan) =>
125
+ const applyWritePlan = ({ root, preconditions, writes, move }: WritePlan) =>
118
126
  Effect.tryPromise({
119
127
  try: async () => {
120
128
  const lockPath = join(root, ".agency-graph-mutation.lock")
@@ -139,6 +147,18 @@ const applyWritePlan = ({ root, writes, move }: WritePlan) =>
139
147
  let moved = false
140
148
  let rollbackFailed = false
141
149
  try {
150
+ for (const precondition of preconditions) {
151
+ const content = await Bun.file(precondition.path).text()
152
+ const currentRevision = documentRevision(content)
153
+ if (currentRevision !== precondition.revision) {
154
+ throw new RevisionConflictError({
155
+ path: relative(root, precondition.path),
156
+ expectedRevision: precondition.revision,
157
+ currentRevision,
158
+ message: `Revision conflict for ${relative(root, precondition.path)}`,
159
+ })
160
+ }
161
+ }
142
162
  for (const write of staged) await Bun.write(write.stage, write.content)
143
163
  if (move) {
144
164
  await rename(move.from, move.to)
@@ -195,7 +215,8 @@ const applyWritePlan = ({ root, writes, move }: WritePlan) =>
195
215
  }
196
216
  },
197
217
  catch: (cause) =>
198
- cause instanceof GraphMutationError
218
+ cause instanceof GraphMutationError ||
219
+ cause instanceof RevisionConflictError
199
220
  ? cause
200
221
  : new GraphMutationError({
201
222
  message:
@@ -212,6 +233,19 @@ const contentWith = (
212
233
  Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
213
234
  )
214
235
 
236
+ type RevisionedRecord = {
237
+ readonly path: string
238
+ readonly revision: string
239
+ }
240
+
241
+ const precondition = (
242
+ record: RevisionedRecord,
243
+ ifRevision?: string,
244
+ ): { readonly path: string; readonly revision: string } => ({
245
+ path: record.path,
246
+ revision: ifRevision ?? record.revision,
247
+ })
248
+
215
249
  const result = (
216
250
  root: string,
217
251
  operation: string,
@@ -250,6 +284,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
250
284
  id: string,
251
285
  updates: EpicUpdates,
252
286
  startPath: string = process.cwd(),
287
+ ifRevision?: string,
253
288
  ) =>
254
289
  Effect.gen(function* () {
255
290
  const workbase = yield* WorkbaseService
@@ -289,10 +324,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
289
324
  })
290
325
  }
291
326
  const content = yield* contentWith(record, data)
292
- if (content === record.content)
327
+ if (content === record.content) {
328
+ if (ifRevision)
329
+ yield* applyWritePlan({
330
+ root,
331
+ preconditions: [precondition(record, ifRevision)],
332
+ writes: [],
333
+ })
293
334
  return result(root, "epic.update", "epic", id, [])
335
+ }
294
336
  yield* applyWritePlan({
295
337
  root,
338
+ preconditions: [precondition(record, ifRevision)],
296
339
  writes: [{ path: record.path, content }],
297
340
  })
298
341
  return result(root, "epic.update", "epic", id, [record.path])
@@ -302,6 +345,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
302
345
  id: string,
303
346
  updates: TaskUpdates,
304
347
  startPath: string = process.cwd(),
348
+ ifRevision?: string,
305
349
  ) =>
306
350
  Effect.gen(function* () {
307
351
  const workbase = yield* WorkbaseService
@@ -410,10 +454,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
410
454
  }
411
455
  }
412
456
  const content = yield* contentWith(record, data)
413
- if (content === record.content)
457
+ if (content === record.content) {
458
+ if (ifRevision)
459
+ yield* applyWritePlan({
460
+ root,
461
+ preconditions: [precondition(record, ifRevision)],
462
+ writes: [],
463
+ })
414
464
  return result(root, "task.update", "task", id, [])
465
+ }
415
466
  yield* applyWritePlan({
416
467
  root,
468
+ preconditions: [precondition(record, ifRevision)],
417
469
  writes: [{ path: record.path, content }],
418
470
  })
419
471
  return result(root, "task.update", "task", id, [record.path])
@@ -424,6 +476,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
424
476
  id: string,
425
477
  updates: PhaseUpdates,
426
478
  startPath: string = process.cwd(),
479
+ ifRevision?: string,
427
480
  ) =>
428
481
  Effect.gen(function* () {
429
482
  const workbase = yield* WorkbaseService
@@ -515,10 +568,18 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
515
568
  }
516
569
  }
517
570
  const content = yield* contentWith(record, data)
518
- if (content === record.content)
571
+ if (content === record.content) {
572
+ if (ifRevision)
573
+ yield* applyWritePlan({
574
+ root,
575
+ preconditions: [precondition(record, ifRevision)],
576
+ writes: [],
577
+ })
519
578
  return result(root, "phase.update", "phase", id, [])
579
+ }
520
580
  yield* applyWritePlan({
521
581
  root,
582
+ preconditions: [precondition(record, ifRevision)],
522
583
  writes: [{ path: record.path, content }],
523
584
  })
524
585
  return result(root, "phase.update", "phase", id, [record.path])
@@ -529,6 +590,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
529
590
  id: string,
530
591
  dependencyId: string,
531
592
  startPath: string = process.cwd(),
593
+ ifRevision?: string,
532
594
  ) =>
533
595
  Effect.gen(function* () {
534
596
  const workbase = yield* WorkbaseService
@@ -582,6 +644,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
582
644
  const content = yield* contentWith(epic, data)
583
645
  yield* applyWritePlan({
584
646
  root,
647
+ preconditions: [precondition(task, ifRevision), precondition(epic)],
585
648
  writes: [{ path: epic.path, content }],
586
649
  })
587
650
  return result(root, `task.dependency.${operation}`, "task", id, [
@@ -595,12 +658,14 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
595
658
  id: string,
596
659
  dependencyId: string,
597
660
  startPath: string = process.cwd(),
661
+ ifRevision?: string,
598
662
  ) =>
599
663
  Effect.gen(function* () {
600
664
  const workbase = yield* WorkbaseService
601
665
  const tasks = yield* TaskService
602
666
  const root = yield* workbase.discover(startPath)
603
667
  const task = yield* tasks.show(taskId, root)
668
+ const phase = yield* (yield* PhaseService).show(taskId, id, root)
604
669
  if (!("phases" in task.data))
605
670
  return yield* new GraphMutationError({
606
671
  message: `Task '${taskId}' does not have phases`,
@@ -641,6 +706,10 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
641
706
  const content = yield* contentWith(task, data)
642
707
  yield* applyWritePlan({
643
708
  root,
709
+ preconditions: [
710
+ precondition(phase, ifRevision),
711
+ precondition(task),
712
+ ],
644
713
  writes: [{ path: task.path, content }],
645
714
  })
646
715
  return result(root, `phase.dependency.${operation}`, "phase", id, [
@@ -652,6 +721,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
652
721
  id: string,
653
722
  newId: string,
654
723
  startPath: string = process.cwd(),
724
+ ifRevision?: string,
655
725
  ) =>
656
726
  Effect.gen(function* () {
657
727
  const workbase = yield* WorkbaseService
@@ -698,7 +768,17 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
698
768
  content: yield* contentWith(task, data),
699
769
  })
700
770
  }
701
- yield* applyWritePlan({ root, writes, move: { from, to } })
771
+ yield* applyWritePlan({
772
+ root,
773
+ preconditions: [
774
+ precondition(epic, ifRevision),
775
+ ...allTasks
776
+ .filter((task) => task.data.epic === id)
777
+ .map((task) => precondition(task)),
778
+ ],
779
+ writes,
780
+ move: { from, to },
781
+ })
702
782
  return result(
703
783
  root,
704
784
  "epic.rename",
@@ -713,6 +793,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
713
793
  id: string,
714
794
  newId: string,
715
795
  startPath: string = process.cwd(),
796
+ ifRevision?: string,
716
797
  ) =>
717
798
  Effect.gen(function* () {
718
799
  const workbase = yield* WorkbaseService
@@ -736,6 +817,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
736
817
  return yield* new GraphMutationError({
737
818
  message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
738
819
  })
820
+ const movedPhases: PhaseRecord[] = []
739
821
  if ("phases" in task.data) {
740
822
  for (const phase of task.data.phases) {
741
823
  const record = yield* (yield* PhaseService).show(
@@ -743,6 +825,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
743
825
  phase.id,
744
826
  root,
745
827
  )
828
+ movedPhases.push(record)
746
829
  if (record.data.claim?.state === "active")
747
830
  return yield* new GraphMutationError({
748
831
  message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
@@ -762,6 +845,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
762
845
  }
763
846
  }
764
847
  const writes: { path: string; content: string }[] = []
848
+ const affectedEpics: EpicRecord[] = []
765
849
  for (const epic of yield* epics.list(root)) {
766
850
  if (
767
851
  !epic.data.tasks.some(
@@ -769,6 +853,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
769
853
  )
770
854
  )
771
855
  continue
856
+ affectedEpics.push(epic)
772
857
  const data = {
773
858
  ...epic.data,
774
859
  tasks: epic.data.tasks.map((item) => ({
@@ -789,7 +874,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
789
874
  content: yield* contentWith(epic, data),
790
875
  })
791
876
  }
792
- yield* applyWritePlan({ root, writes, move: { from, to } })
877
+ yield* applyWritePlan({
878
+ root,
879
+ preconditions: [
880
+ precondition(task, ifRevision),
881
+ ...movedPhases.map((phase) => precondition(phase)),
882
+ ...affectedEpics.map((epic) => precondition(epic)),
883
+ ],
884
+ writes,
885
+ move: { from, to },
886
+ })
793
887
  return result(
794
888
  root,
795
889
  "task.rename",
@@ -805,6 +899,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
805
899
  id: string,
806
900
  newId: string,
807
901
  startPath: string = process.cwd(),
902
+ ifRevision?: string,
808
903
  ) =>
809
904
  Effect.gen(function* () {
810
905
  const workbase = yield* WorkbaseService
@@ -856,6 +951,10 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
856
951
  const content = yield* contentWith(task, data)
857
952
  yield* applyWritePlan({
858
953
  root,
954
+ preconditions: [
955
+ precondition(phase, ifRevision),
956
+ precondition(task),
957
+ ],
859
958
  writes: [{ path: task.path, content }],
860
959
  move: { from, to },
861
960
  })
@@ -873,6 +972,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
873
972
  id: string,
874
973
  epicId: string | null,
875
974
  startPath: string = process.cwd(),
975
+ ifRevision?: string,
876
976
  ) =>
877
977
  Effect.gen(function* () {
878
978
  const workbase = yield* WorkbaseService
@@ -881,8 +981,15 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
881
981
  const root = yield* workbase.discover(startPath)
882
982
  const task = yield* tasks.show(id, root)
883
983
  const sourceId = task.data.epic
884
- if (sourceId === epicId)
984
+ if (sourceId === epicId) {
985
+ if (ifRevision)
986
+ yield* applyWritePlan({
987
+ root,
988
+ preconditions: [precondition(task, ifRevision)],
989
+ writes: [],
990
+ })
885
991
  return result(root, "task.move", "task", id, [])
992
+ }
886
993
  const source = sourceId
887
994
  ? yield* epics.show(sourceId, root)
888
995
  : undefined
@@ -938,7 +1045,15 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
938
1045
  path: task.path,
939
1046
  content: yield* contentWith(task, taskData),
940
1047
  })
941
- yield* applyWritePlan({ root, writes })
1048
+ yield* applyWritePlan({
1049
+ root,
1050
+ preconditions: [
1051
+ precondition(task, ifRevision),
1052
+ ...(source ? [precondition(source)] : []),
1053
+ ...(target ? [precondition(target)] : []),
1054
+ ],
1055
+ writes,
1056
+ })
942
1057
  return result(
943
1058
  root,
944
1059
  "task.move",
@@ -21,6 +21,7 @@ import {
21
21
  } from "../readiness"
22
22
  import { parseFrontmatter } from "../workbase/frontmatter"
23
23
  import { normalizePullRequestRecord } from "../workbase/delivery-command"
24
+ import { documentRevision } from "../workbase/document-revision"
24
25
  import {
25
26
  EpicFrontmatter,
26
27
  PhaseFrontmatter,
@@ -74,9 +75,6 @@ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
74
75
  const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
75
76
  `execution-unit:phase/${taskId}/${phaseId}`
76
77
 
77
- const hash = (content: string) =>
78
- new Bun.CryptoHasher("sha256").update(content).digest("hex")
79
-
80
78
  const decode = <S extends Schema.Schema.AnyNoContext>(
81
79
  schema: S,
82
80
  input: unknown,
@@ -162,7 +160,7 @@ export class GraphService extends Effect.Service<GraphService>()(
162
160
  return {
163
161
  id,
164
162
  path,
165
- sha256: hash(content),
163
+ sha256: documentRevision(content),
166
164
  data: decoded.value,
167
165
  body: parsed.body,
168
166
  } satisfies Document<Schema.Schema.Type<S>>
@@ -16,16 +16,18 @@ import {
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
+ import { documentRevision } from "../workbase/document-revision"
19
20
 
20
21
  class PhaseError extends Data.TaggedError("PhaseError")<{
21
22
  readonly message: string
22
23
  }> {}
23
24
 
24
- interface PhaseRecord {
25
+ export interface PhaseRecord {
25
26
  readonly taskId: string
26
27
  readonly id: string
27
28
  readonly path: string
28
29
  readonly content: string
30
+ readonly revision: string
29
31
  readonly data: PhaseData
30
32
  }
31
33
 
@@ -261,7 +263,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
261
263
  task.path,
262
264
  formatMarkdownDocument(convertedTaskData, parsedTask.body),
263
265
  )
264
- return { taskId, id, path, content, data } satisfies PhaseRecord
266
+ return {
267
+ taskId,
268
+ id,
269
+ path,
270
+ content,
271
+ revision: documentRevision(content),
272
+ data,
273
+ } satisfies PhaseRecord
265
274
  }
266
275
 
267
276
  yield* fs.createDirectory(directory)
@@ -283,7 +292,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
283
292
  formatMarkdownDocument(updatedTaskData, parsedTask.body),
284
293
  )
285
294
 
286
- return { taskId, id, path, content, data } satisfies PhaseRecord
295
+ return {
296
+ taskId,
297
+ id,
298
+ path,
299
+ content,
300
+ revision: documentRevision(content),
301
+ data,
302
+ } satisfies PhaseRecord
287
303
  }),
288
304
 
289
305
  list: (taskId: string, startPath: string = process.cwd()) =>
@@ -311,6 +327,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
311
327
  id: entry.name,
312
328
  path,
313
329
  content,
330
+ revision: documentRevision(content),
314
331
  data,
315
332
  })
316
333
  }
@@ -363,7 +380,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
363
380
  const data = { ...record.data, status: validStatus }
364
381
  const content = formatMarkdownDocument(data, parsed.body)
365
382
  yield* fs.writeFile(record.path, content)
366
- return { ...record, content, data } satisfies PhaseRecord
383
+ return {
384
+ ...record,
385
+ content,
386
+ revision: documentRevision(content),
387
+ data,
388
+ } satisfies PhaseRecord
367
389
  }),
368
390
  }),
369
391
  },
@@ -16,6 +16,7 @@ import {
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
+ import { documentRevision } from "../workbase/document-revision"
19
20
 
20
21
  class TaskError extends Data.TaggedError("TaskError")<{
21
22
  readonly message: string
@@ -25,6 +26,7 @@ interface TaskRecord {
25
26
  readonly id: string
26
27
  readonly path: string
27
28
  readonly content: string
29
+ readonly revision: string
28
30
  readonly data: TaskData
29
31
  }
30
32
 
@@ -158,7 +160,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
158
160
  yield* fs.writeFile(parentEpic.path, updated)
159
161
  }
160
162
 
161
- return { id, path, content, data } satisfies TaskRecord
163
+ return {
164
+ id,
165
+ path,
166
+ content,
167
+ revision: documentRevision(content),
168
+ data,
169
+ } satisfies TaskRecord
162
170
  }),
163
171
 
164
172
  list: (startPath: string = process.cwd()) =>
@@ -178,7 +186,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
178
186
  const content = yield* fs.readFile(path)
179
187
  const parsed = yield* parseFrontmatter(content, path)
180
188
  const data = yield* decodeTask(parsed.data)
181
- records.push({ id: entry.name, path, content, data })
189
+ records.push({
190
+ id: entry.name,
191
+ path,
192
+ content,
193
+ revision: documentRevision(content),
194
+ data,
195
+ })
182
196
  }
183
197
  return records
184
198
  }),
@@ -233,7 +247,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
233
247
  const data = { ...record.data, status: validStatus }
234
248
  const content = formatMarkdownDocument(data, parsed.body)
235
249
  yield* fs.writeFile(record.path, content)
236
- return { ...record, content, data } satisfies TaskRecord
250
+ return {
251
+ ...record,
252
+ content,
253
+ revision: documentRevision(content),
254
+ data,
255
+ } satisfies TaskRecord
237
256
  }),
238
257
  }),
239
258
  }) {}
package/src/work-view.ts CHANGED
@@ -23,6 +23,7 @@ export interface WorkViewRow {
23
23
  readonly kind: "epic" | "task" | "phase"
24
24
  readonly id: string
25
25
  readonly key: string
26
+ readonly revision: string
26
27
  readonly parent: string
27
28
  readonly status: WorkStatus
28
29
  readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
@@ -91,6 +92,7 @@ const rowFor = (
91
92
  kind: node.kind,
92
93
  id,
93
94
  key: node.key,
95
+ revision: node.data.sha256,
94
96
  parent,
95
97
  status: node.status,
96
98
  readiness: readinessLabel(node),
@@ -1,2 +1,18 @@
1
+ import { Data } from "effect"
2
+
1
3
  export const documentRevision = (content: string) =>
2
4
  new Bun.CryptoHasher("sha256").update(content).digest("hex")
5
+
6
+ export const isDocumentRevision = (revision: string) =>
7
+ /^[a-f0-9]{64}$/.test(revision)
8
+
9
+ export class RevisionConflictError extends Data.TaggedError(
10
+ "RevisionConflictError",
11
+ )<{
12
+ readonly message: string
13
+ readonly path: string
14
+ readonly target?: string
15
+ readonly expectedRevision: string
16
+ readonly currentRevision: string
17
+ readonly claim?: unknown
18
+ }> {}