@markjaquith/agency 3.2.1 → 3.2.3

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 (54) hide show
  1. package/README.md +30 -60
  2. package/cli-main.ts +38 -72
  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 +13 -132
  7. package/src/cli-parser.ts +10 -101
  8. package/src/cli.test.ts +12 -111
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/push.test.ts +4 -2
  11. package/src/commands/push.ts +2 -0
  12. package/src/commands/sync.ts +3 -3
  13. package/src/commands/validate.ts +3 -1
  14. package/src/commands/work.test.ts +70 -8
  15. package/src/commands/work.ts +15 -8
  16. package/src/graph-schema.ts +0 -2
  17. package/src/protocol.test.ts +24 -25
  18. package/src/protocol.ts +56 -15
  19. package/src/readiness.test.ts +2 -2
  20. package/src/services/ArchiveBulkService.test.ts +0 -88
  21. package/src/services/ArchiveService.ts +0 -32
  22. package/src/services/FileSystemService.ts +2 -0
  23. package/src/services/GraphMutationService.ts +0 -26
  24. package/src/services/GraphService.test.ts +1 -1
  25. package/src/services/IntegrationService.test.ts +4 -4
  26. package/src/services/LifecycleTransaction.ts +5 -1
  27. package/src/services/PhaseService.ts +1 -8
  28. package/src/services/PushService.test.ts +110 -4
  29. package/src/services/PushService.ts +435 -85
  30. package/src/services/ReadinessService.test.ts +0 -34
  31. package/src/services/ReadinessService.ts +1 -20
  32. package/src/services/ReviewService.test.ts +0 -64
  33. package/src/services/ReviewService.ts +0 -5
  34. package/src/services/SyncService.test.ts +75 -88
  35. package/src/services/SyncService.ts +52 -123
  36. package/src/services/TaskPhaseService.test.ts +13 -1
  37. package/src/services/TaskService.ts +1 -7
  38. package/src/services/WorkbaseService.ts +0 -3
  39. package/src/services/WorktreeService.test.ts +192 -1
  40. package/src/services/WorktreeService.ts +169 -34
  41. package/src/test-utils.ts +0 -2
  42. package/src/usage-log.test.ts +11 -1
  43. package/src/usage-log.ts +22 -4
  44. package/src/utils/process.test.ts +10 -0
  45. package/src/utils/process.ts +59 -6
  46. package/src/workbase/AGENTS.md +9 -15
  47. package/src/workbase/agent-command.test.ts +0 -3
  48. package/src/workbase/agent-command.ts +0 -6
  49. package/src/workbase/document-revision.ts +0 -4
  50. package/src/workbase/schemas.test.ts +12 -25
  51. package/src/workbase/schemas.ts +0 -16
  52. package/src/commands/claim.ts +0 -122
  53. package/src/services/ClaimService.test.ts +0 -415
  54. package/src/services/ClaimService.ts +0 -608
@@ -1,608 +0,0 @@
1
- import { Schema, TreeFormatter } from "@effect/schema"
2
- import { Data, Effect, Either } from "effect"
3
- import { randomUUID } from "node:crypto"
4
- import {
5
- open,
6
- readFile,
7
- rename,
8
- stat,
9
- unlink,
10
- writeFile,
11
- } from "node:fs/promises"
12
- import { basename, dirname, join, relative } from "node:path"
13
- import { WorkbaseService } from "./WorkbaseService"
14
- import {
15
- documentRevision,
16
- isDocumentRevision,
17
- RevisionConflictError,
18
- } from "../workbase/document-revision"
19
- import {
20
- formatMarkdownDocument,
21
- parseFrontmatterSync,
22
- } from "../workbase/frontmatter"
23
- import {
24
- PhaseFrontmatter,
25
- TaskFrontmatter,
26
- type ClaimRecord,
27
- type PullRequestRecord,
28
- type PhaseFrontmatter as PhaseData,
29
- type TaskFrontmatter as TaskData,
30
- } from "../workbase/schemas"
31
- import {
32
- buildNonPrCompletion,
33
- type NonPrCompletionInput,
34
- } from "../workbase/completion"
35
-
36
- class ClaimError extends Data.TaggedError("ClaimError")<{
37
- readonly message: string
38
- readonly target?: string
39
- }> {}
40
-
41
- class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
42
- readonly message: string
43
- readonly target: string
44
- readonly currentRevision: string
45
- readonly claim?: ClaimRecord
46
- readonly legacyStatus?: "working" | "delegated"
47
- }> {}
48
-
49
- class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
50
- readonly message: string
51
- readonly target: string
52
- readonly currentRevision: string
53
- readonly sessionId: string
54
- readonly claim?: ClaimRecord
55
- }> {}
56
-
57
- interface ClaimTarget {
58
- readonly kind: "task" | "phase"
59
- readonly root: string
60
- readonly taskId: string
61
- readonly phaseId?: string
62
- readonly path: string
63
- readonly label: string
64
- }
65
-
66
- const resolveTarget = async (
67
- root: string,
68
- taskId: string,
69
- phaseId?: string,
70
- ): Promise<ClaimTarget> => {
71
- const taskPath = join(root, "tasks", taskId, "TASK.md")
72
- const path = phaseId
73
- ? join(root, "tasks", taskId, "phases", phaseId, "PHASE.md")
74
- : taskPath
75
- try {
76
- await stat(taskPath)
77
- } catch {
78
- throw new ClaimError({ message: `Task '${taskId}' does not exist` })
79
- }
80
- if (phaseId) {
81
- try {
82
- await stat(path)
83
- } catch {
84
- throw new ClaimError({
85
- message: `Phase '${phaseId}' does not exist on task '${taskId}'`,
86
- })
87
- }
88
- }
89
- return phaseId
90
- ? {
91
- kind: "phase",
92
- root,
93
- taskId,
94
- phaseId,
95
- path,
96
- label: `phase '${taskId}/${phaseId}'`,
97
- }
98
- : {
99
- kind: "task",
100
- root,
101
- taskId,
102
- path,
103
- label: `task '${taskId}'`,
104
- }
105
- }
106
-
107
- interface ClaimInput {
108
- readonly taskId: string
109
- readonly phaseId?: string
110
- readonly claimant: string
111
- readonly agent: string
112
- readonly sessionId: string
113
- readonly revision: string
114
- readonly expiresAt?: string
115
- readonly now?: Date
116
- }
117
-
118
- interface OwnedClaimInput {
119
- readonly taskId: string
120
- readonly phaseId?: string
121
- readonly sessionId: string
122
- readonly revision: string
123
- readonly now?: Date
124
- }
125
-
126
- interface FinishInput extends OwnedClaimInput {
127
- readonly outcome: "done" | "dropped"
128
- readonly nonPrCompletion?: NonPrCompletionInput
129
- }
130
-
131
- interface ExpireClaimInput {
132
- readonly taskId: string
133
- readonly phaseId?: string
134
- readonly revision: string
135
- readonly now?: Date
136
- }
137
-
138
- interface ReconcileInput {
139
- readonly taskId: string
140
- readonly phaseId?: string
141
- readonly revision: string
142
- readonly pr?: string | PullRequestRecord
143
- readonly status?: "done"
144
- }
145
-
146
- const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
147
-
148
- type SingleTaskData = Extract<TaskData, { readonly repo: string }>
149
- type ReviewTaskData = Extract<TaskData, { readonly review: unknown }>
150
- type ExecutionData = SingleTaskData | ReviewTaskData | PhaseData
151
-
152
- const isTaggedClaimError = (
153
- error: unknown,
154
- ): error is
155
- | ClaimError
156
- | RevisionConflictError
157
- | ClaimConflictError
158
- | ClaimOwnershipError =>
159
- typeof error === "object" &&
160
- error !== null &&
161
- "_tag" in error &&
162
- typeof error._tag === "string" &&
163
- [
164
- "ClaimError",
165
- "RevisionConflictError",
166
- "ClaimConflictError",
167
- "ClaimOwnershipError",
168
- ].includes(error._tag)
169
-
170
- const decodeExecution = (target: ClaimTarget, input: unknown) => {
171
- const schema: Schema.Schema<any> =
172
- target.kind === "task" ? TaskFrontmatter : PhaseFrontmatter
173
- const result = Schema.decodeUnknownEither(schema, {
174
- errors: "all",
175
- onExcessProperty: "error",
176
- })(input)
177
- if (Either.isLeft(result)) {
178
- throw new ClaimError({
179
- target: target.label,
180
- message: TreeFormatter.formatErrorSync(result.left),
181
- })
182
- }
183
- if (target.kind === "task" && "phases" in result.right) {
184
- throw new ClaimError({
185
- target: target.label,
186
- message: `Task '${target.taskId}' has multiple phases; claim a phase instead`,
187
- })
188
- }
189
- return result.right as ExecutionData
190
- }
191
-
192
- const assertRevision = (revision: string) => {
193
- if (!isDocumentRevision(revision)) {
194
- throw new ClaimError({
195
- message: "Revision must be a 64-character SHA-256 hash",
196
- })
197
- }
198
- }
199
-
200
- const assertIdentity = (label: string, value: string) => {
201
- if (!value.trim())
202
- throw new ClaimError({ message: `${label} must not be empty` })
203
- }
204
-
205
- const isIsoTimestamp = (value: string) =>
206
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
207
- Number.isFinite(Date.parse(value))
208
-
209
- const isUnexpired = (claim: ClaimRecord, now: Date) =>
210
- claim.state === "active" &&
211
- (claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
212
-
213
- const acquireLock = async (lockPath: string, label: string) => {
214
- for (let attempt = 0; attempt < 1_750; attempt += 1) {
215
- try {
216
- const handle = await open(lockPath, "wx")
217
- return { handle, lockPath }
218
- } catch (error) {
219
- if (
220
- !(error instanceof Error) ||
221
- !("code" in error) ||
222
- error.code !== "EEXIST"
223
- ) {
224
- throw error
225
- }
226
- try {
227
- const lock = await stat(lockPath)
228
- if (Date.now() - lock.mtimeMs > 30_000) await unlink(lockPath)
229
- } catch {
230
- // Another process released the lock between checks.
231
- }
232
- await Bun.sleep(20)
233
- }
234
- }
235
- throw new ClaimError({ message: `Timed out waiting to update ${label}` })
236
- }
237
-
238
- const updateAtomically = async <T>(
239
- target: ClaimTarget,
240
- expectedRevision: string,
241
- update: (
242
- data: ExecutionData,
243
- now: Date,
244
- ) => T & {
245
- readonly data: ExecutionData
246
- },
247
- now: Date,
248
- ) => {
249
- assertRevision(expectedRevision)
250
- const graphLock = await acquireLock(
251
- join(target.root, ".agency-graph-mutation.lock"),
252
- target.root,
253
- )
254
- let documentLock: Awaited<ReturnType<typeof acquireLock>> | undefined
255
- let temporaryPath: string | undefined
256
- try {
257
- documentLock = await acquireLock(`${target.path}.claim.lock`, target.path)
258
- const content = await readFile(target.path, "utf8")
259
- const currentRevision = documentRevision(content)
260
- const parsed = parseFrontmatterSync(content, target.path)
261
- const current = decodeExecution(target, parsed.data)
262
- if (currentRevision !== expectedRevision) {
263
- throw new RevisionConflictError({
264
- path: relative(target.root, target.path),
265
- target: target.label,
266
- expectedRevision,
267
- currentRevision,
268
- claim: current.claim,
269
- message: `Revision conflict for ${target.label}`,
270
- })
271
- }
272
- const result = update(current, now)
273
- const updatedContent = formatMarkdownDocument(result.data, parsed.body)
274
- temporaryPath = join(
275
- dirname(target.path),
276
- `.${basename(target.path)}.${process.pid}.${randomUUID()}.tmp`,
277
- )
278
- await writeFile(temporaryPath, updatedContent, { flag: "wx" })
279
- await rename(temporaryPath, target.path)
280
- temporaryPath = undefined
281
- return {
282
- ...result,
283
- target: target.label,
284
- previousRevision: currentRevision,
285
- revision: documentRevision(updatedContent),
286
- }
287
- } finally {
288
- if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
289
- await documentLock?.handle.close().catch(() => undefined)
290
- if (documentLock) await unlink(documentLock.lockPath).catch(() => undefined)
291
- await graphLock.handle.close().catch(() => undefined)
292
- await unlink(graphLock.lockPath).catch(() => undefined)
293
- }
294
- }
295
-
296
- const operation = <T>(run: () => Promise<T>) =>
297
- Effect.tryPromise({
298
- try: run,
299
- catch: (error) =>
300
- isTaggedClaimError(error)
301
- ? error
302
- : new ClaimError({
303
- message: error instanceof Error ? error.message : String(error),
304
- }),
305
- })
306
-
307
- export class ClaimService extends Effect.Service<ClaimService>()(
308
- "ClaimService",
309
- {
310
- sync: () => ({
311
- inspect: (
312
- taskId: string,
313
- phaseId?: string,
314
- startPath: string = process.cwd(),
315
- ) =>
316
- Effect.gen(function* () {
317
- const workbase = yield* WorkbaseService
318
- const root = yield* workbase.discover(startPath)
319
- const target = yield* operation(() =>
320
- resolveTarget(root, taskId, phaseId),
321
- )
322
- const content = yield* operation(() => readFile(target.path, "utf8"))
323
- const parsed = parseFrontmatterSync(content, target.path)
324
- const data = decodeExecution(target, parsed.data)
325
- if (!phaseId && "phases" in data) {
326
- return yield* new ClaimError({
327
- target: target.label,
328
- message: `Task '${taskId}' has multiple phases; claim a phase instead`,
329
- })
330
- }
331
- return {
332
- target,
333
- revision: documentRevision(content),
334
- data,
335
- }
336
- }),
337
-
338
- expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
339
- Effect.gen(function* () {
340
- const service = yield* ClaimService
341
- const inspected = yield* service.inspect(
342
- input.taskId,
343
- input.phaseId,
344
- startPath,
345
- )
346
- return yield* operation(() =>
347
- updateAtomically(
348
- inspected.target,
349
- input.revision,
350
- (data, now) => {
351
- if (
352
- data.claim?.state !== "active" ||
353
- data.claim.expiresAt === undefined ||
354
- Date.parse(data.claim.expiresAt) > now.getTime() ||
355
- (data.status !== "working" && data.status !== "delegated")
356
- ) {
357
- throw new ClaimError({
358
- target: inspected.target.label,
359
- message: `${inspected.target.label} does not have an expired active claim`,
360
- })
361
- }
362
- const claim: ClaimRecord = {
363
- ...data.claim,
364
- state: "released",
365
- releasedAt: now.toISOString(),
366
- }
367
- return { data: { ...data, status: "open", claim }, claim }
368
- },
369
- input.now ?? new Date(),
370
- ),
371
- )
372
- }),
373
-
374
- reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
375
- Effect.gen(function* () {
376
- if (
377
- typeof input.pr === "string" &&
378
- input.pr !== undefined &&
379
- !PR_URL.test(input.pr)
380
- ) {
381
- return yield* new ClaimError({
382
- message: `Invalid GitHub pull request URL: ${input.pr}`,
383
- })
384
- }
385
- const service = yield* ClaimService
386
- const inspected = yield* service.inspect(
387
- input.taskId,
388
- input.phaseId,
389
- startPath,
390
- )
391
- return yield* operation(() =>
392
- updateAtomically(
393
- inspected.target,
394
- input.revision,
395
- (data) => {
396
- if (input.status === "done" && data.claim?.state === "active") {
397
- throw new ClaimConflictError({
398
- target: inspected.target.label,
399
- currentRevision: input.revision,
400
- claim: data.claim,
401
- message: `${inspected.target.label} has an active claim`,
402
- })
403
- }
404
- return {
405
- data: {
406
- ...data,
407
- ...(input.pr !== undefined ? { pr: input.pr } : {}),
408
- ...(input.status !== undefined
409
- ? { status: input.status }
410
- : {}),
411
- },
412
- }
413
- },
414
- new Date(),
415
- ),
416
- )
417
- }),
418
-
419
- claim: (input: ClaimInput, startPath: string = process.cwd()) =>
420
- Effect.gen(function* () {
421
- for (const [label, value] of [
422
- ["Claimant", input.claimant],
423
- ["Agent", input.agent],
424
- ["Session ID", input.sessionId],
425
- ] as const) {
426
- assertIdentity(label, value)
427
- }
428
- const service = yield* ClaimService
429
- const inspected = yield* service.inspect(
430
- input.taskId,
431
- input.phaseId,
432
- startPath,
433
- )
434
- const now = input.now ?? new Date()
435
- if (
436
- input.expiresAt !== undefined &&
437
- (!isIsoTimestamp(input.expiresAt) ||
438
- Date.parse(input.expiresAt) <= now.getTime())
439
- ) {
440
- return yield* new ClaimError({
441
- message: "Claim expiry must be a future ISO-8601 timestamp",
442
- })
443
- }
444
- return yield* operation(() =>
445
- updateAtomically(
446
- inspected.target,
447
- input.revision,
448
- (data, operationTime) => {
449
- const replacingExpiredClaim =
450
- data.claim?.state === "active" &&
451
- !isUnexpired(data.claim, operationTime)
452
- if (data.claim && isUnexpired(data.claim, operationTime)) {
453
- throw new ClaimConflictError({
454
- target: inspected.target.label,
455
- currentRevision: input.revision,
456
- claim: data.claim,
457
- message: `${inspected.target.label} is claimed by '${data.claim.agent}'`,
458
- })
459
- }
460
- if (
461
- !data.claim &&
462
- (data.status === "working" || data.status === "delegated")
463
- ) {
464
- throw new ClaimConflictError({
465
- target: inspected.target.label,
466
- currentRevision: input.revision,
467
- legacyStatus: data.status,
468
- message: `${inspected.target.label} has legacy '${data.status}' ownership; reopen it before claiming`,
469
- })
470
- }
471
- if (data.status !== "open" && !replacingExpiredClaim) {
472
- throw new ClaimError({
473
- target: inspected.target.label,
474
- message: `${inspected.target.label} cannot be claimed while ${data.status}`,
475
- })
476
- }
477
- const claim: ClaimRecord = {
478
- claimant: input.claimant.trim(),
479
- agent: input.agent.trim(),
480
- sessionId: input.sessionId.trim(),
481
- startedAt: operationTime.toISOString(),
482
- targetRevision: input.revision,
483
- ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
484
- state: "active",
485
- }
486
- return { data: { ...data, status: "working", claim }, claim }
487
- },
488
- now,
489
- ),
490
- )
491
- }),
492
-
493
- release: (input: OwnedClaimInput, startPath: string = process.cwd()) =>
494
- Effect.gen(function* () {
495
- assertIdentity("Session ID", input.sessionId)
496
- const service = yield* ClaimService
497
- const inspected = yield* service.inspect(
498
- input.taskId,
499
- input.phaseId,
500
- startPath,
501
- )
502
- return yield* operation(() =>
503
- updateAtomically(
504
- inspected.target,
505
- input.revision,
506
- (data, now) => {
507
- if (
508
- !data.claim ||
509
- data.claim.state !== "active" ||
510
- data.claim.sessionId !== input.sessionId
511
- ) {
512
- throw new ClaimOwnershipError({
513
- target: inspected.target.label,
514
- currentRevision: input.revision,
515
- sessionId: input.sessionId,
516
- claim: data.claim,
517
- message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
518
- })
519
- }
520
- const claim: ClaimRecord = {
521
- ...data.claim,
522
- state: "released",
523
- releasedAt: now.toISOString(),
524
- }
525
- return { data: { ...data, status: "open", claim }, claim }
526
- },
527
- input.now ?? new Date(),
528
- ),
529
- )
530
- }),
531
-
532
- finish: (input: FinishInput, startPath: string = process.cwd()) =>
533
- Effect.gen(function* () {
534
- assertIdentity("Session ID", input.sessionId)
535
- if (input.nonPrCompletion && input.outcome !== "done") {
536
- return yield* new ClaimError({
537
- message: "Non-PR completion is valid only with a done outcome",
538
- })
539
- }
540
- const service = yield* ClaimService
541
- const inspected = yield* service.inspect(
542
- input.taskId,
543
- input.phaseId,
544
- startPath,
545
- )
546
- return yield* operation(() =>
547
- updateAtomically(
548
- inspected.target,
549
- input.revision,
550
- (data, now) => {
551
- if (
552
- !data.claim ||
553
- data.claim.state !== "active" ||
554
- data.claim.sessionId !== input.sessionId
555
- ) {
556
- throw new ClaimOwnershipError({
557
- target: inspected.target.label,
558
- currentRevision: input.revision,
559
- sessionId: input.sessionId,
560
- claim: data.claim,
561
- message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
562
- })
563
- }
564
- if (input.nonPrCompletion && "pr" in data && data.pr !== null) {
565
- throw new ClaimError({
566
- target: inspected.target.label,
567
- message:
568
- "Cannot complete without a pull request while an authoritative pull request is recorded",
569
- })
570
- }
571
- const completionResult = input.nonPrCompletion
572
- ? buildNonPrCompletion(input.nonPrCompletion, now)
573
- : undefined
574
- if (completionResult && "error" in completionResult) {
575
- throw new ClaimError({
576
- target: inspected.target.label,
577
- message: completionResult.error,
578
- })
579
- }
580
- const claim: ClaimRecord = {
581
- ...data.claim,
582
- state: "finished",
583
- finishedAt: now.toISOString(),
584
- outcome: input.outcome,
585
- }
586
- return {
587
- data: {
588
- ...data,
589
- status: completionResult
590
- ? "done"
591
- : input.outcome === "done"
592
- ? "working"
593
- : "dropped",
594
- claim,
595
- ...(completionResult
596
- ? { completion: completionResult.value }
597
- : {}),
598
- },
599
- claim,
600
- }
601
- },
602
- input.now ?? new Date(),
603
- ),
604
- )
605
- }),
606
- }),
607
- },
608
- ) {}