@markjaquith/agency 2.22.0 → 2.24.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.
@@ -16,16 +16,19 @@ import {
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
+ import { documentRevision } from "../workbase/document-revision"
20
+ import { archivedPhaseDirectory } from "../workbase/archive"
19
21
 
20
22
  class PhaseError extends Data.TaggedError("PhaseError")<{
21
23
  readonly message: string
22
24
  }> {}
23
25
 
24
- interface PhaseRecord {
26
+ export interface PhaseRecord {
25
27
  readonly taskId: string
26
28
  readonly id: string
27
29
  readonly path: string
28
30
  readonly content: string
31
+ readonly revision: string
29
32
  readonly data: PhaseData
30
33
  }
31
34
 
@@ -112,6 +115,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
112
115
  message: `Phase '${id}' already exists on task '${taskId}'`,
113
116
  })
114
117
  }
118
+ if (yield* fs.exists(archivedPhaseDirectory(root, taskId, id))) {
119
+ return yield* new PhaseError({
120
+ message: `Phase '${id}' on task '${taskId}' is archived; restore it before reusing this ID`,
121
+ })
122
+ }
115
123
  const knownPhases = new Set(
116
124
  isMultiPhase
117
125
  ? task.data.phases.map((phase) => phase.id)
@@ -261,7 +269,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
261
269
  task.path,
262
270
  formatMarkdownDocument(convertedTaskData, parsedTask.body),
263
271
  )
264
- return { taskId, id, path, content, data } satisfies PhaseRecord
272
+ return {
273
+ taskId,
274
+ id,
275
+ path,
276
+ content,
277
+ revision: documentRevision(content),
278
+ data,
279
+ } satisfies PhaseRecord
265
280
  }
266
281
 
267
282
  yield* fs.createDirectory(directory)
@@ -283,7 +298,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
283
298
  formatMarkdownDocument(updatedTaskData, parsedTask.body),
284
299
  )
285
300
 
286
- return { taskId, id, path, content, data } satisfies PhaseRecord
301
+ return {
302
+ taskId,
303
+ id,
304
+ path,
305
+ content,
306
+ revision: documentRevision(content),
307
+ data,
308
+ } satisfies PhaseRecord
287
309
  }),
288
310
 
289
311
  list: (taskId: string, startPath: string = process.cwd()) =>
@@ -311,6 +333,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
311
333
  id: entry.name,
312
334
  path,
313
335
  content,
336
+ revision: documentRevision(content),
314
337
  data,
315
338
  })
316
339
  }
@@ -363,7 +386,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
363
386
  const data = { ...record.data, status: validStatus }
364
387
  const content = formatMarkdownDocument(data, parsed.body)
365
388
  yield* fs.writeFile(record.path, content)
366
- return { ...record, content, data } satisfies PhaseRecord
389
+ return {
390
+ ...record,
391
+ content,
392
+ revision: documentRevision(content),
393
+ data,
394
+ } satisfies PhaseRecord
367
395
  }),
368
396
  }),
369
397
  },
@@ -16,6 +16,8 @@ import {
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
+ import { documentRevision } from "../workbase/document-revision"
20
+ import { archivedTaskDirectory } from "../workbase/archive"
19
21
 
20
22
  class TaskError extends Data.TaggedError("TaskError")<{
21
23
  readonly message: string
@@ -25,6 +27,7 @@ interface TaskRecord {
25
27
  readonly id: string
26
28
  readonly path: string
27
29
  readonly content: string
30
+ readonly revision: string
28
31
  readonly data: TaskData
29
32
  }
30
33
 
@@ -83,6 +86,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
83
86
  message: `Task '${id}' already exists`,
84
87
  })
85
88
  }
89
+ if (yield* fs.exists(archivedTaskDirectory(root, id))) {
90
+ return yield* new TaskError({
91
+ message: `Task '${id}' is archived; restore it before reusing this ID`,
92
+ })
93
+ }
86
94
 
87
95
  let data: TaskData
88
96
  if (input.multiPhase) {
@@ -158,7 +166,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
158
166
  yield* fs.writeFile(parentEpic.path, updated)
159
167
  }
160
168
 
161
- return { id, path, content, data } satisfies TaskRecord
169
+ return {
170
+ id,
171
+ path,
172
+ content,
173
+ revision: documentRevision(content),
174
+ data,
175
+ } satisfies TaskRecord
162
176
  }),
163
177
 
164
178
  list: (startPath: string = process.cwd()) =>
@@ -178,7 +192,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
178
192
  const content = yield* fs.readFile(path)
179
193
  const parsed = yield* parseFrontmatter(content, path)
180
194
  const data = yield* decodeTask(parsed.data)
181
- records.push({ id: entry.name, path, content, data })
195
+ records.push({
196
+ id: entry.name,
197
+ path,
198
+ content,
199
+ revision: documentRevision(content),
200
+ data,
201
+ })
182
202
  }
183
203
  return records
184
204
  }),
@@ -233,7 +253,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
233
253
  const data = { ...record.data, status: validStatus }
234
254
  const content = formatMarkdownDocument(data, parsed.body)
235
255
  yield* fs.writeFile(record.path, content)
236
- return { ...record, content, data } satisfies TaskRecord
256
+ return {
257
+ ...record,
258
+ content,
259
+ revision: documentRevision(content),
260
+ data,
261
+ } satisfies TaskRecord
237
262
  }),
238
263
  }),
239
264
  }) {}
@@ -88,6 +88,8 @@ interface MaterializeOptions extends BaseCommandOptions {
88
88
  readonly force?: boolean
89
89
  }
90
90
 
91
+ interface RemoveOptions extends BaseCommandOptions {}
92
+
91
93
  export class WorktreeService extends Effect.Service<WorktreeService>()(
92
94
  "WorktreeService",
93
95
  {
@@ -558,6 +560,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
558
560
  taskId: string,
559
561
  phaseId?: string,
560
562
  startPath: string = process.cwd(),
563
+ options: RemoveOptions = {},
561
564
  ) =>
562
565
  Effect.gen(function* () {
563
566
  const fs = yield* FileSystemService
@@ -592,11 +595,22 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
592
595
  }
593
596
 
594
597
  const codeDirectoryExists = yield* fs.isDirectory(codePath)
595
- const removed: string[] = []
596
- for (const alias of [
598
+ const aliases = [
597
599
  execution.repo,
598
600
  ...(execution.repos ?? []).map((reference) => reference.repo),
599
- ]) {
601
+ ]
602
+ if (codeDirectoryExists) {
603
+ const unmanaged = (yield* fs.readDirectory(codePath)).filter(
604
+ (entry) => !aliases.includes(entry.name),
605
+ )
606
+ if (unmanaged.length > 0) {
607
+ return yield* new WorktreeError({
608
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
609
+ })
610
+ }
611
+ }
612
+ const removed: string[] = []
613
+ for (const alias of aliases) {
600
614
  const repositoryPath = join(root, "repos", alias)
601
615
  const checkoutPath = join(codePath, alias)
602
616
  const listed = yield* fs.runCommand(
@@ -643,6 +657,19 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
643
657
  }
644
658
  continue
645
659
  }
660
+ if (checkoutExists) {
661
+ const status = yield* fs.runCommand(
662
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
663
+ { captureOutput: true },
664
+ )
665
+ if (status.exitCode !== 0 || status.stdout.trim()) {
666
+ return yield* new WorktreeError({
667
+ message: `Failed to remove worktree for '${alias}': worktree has uncommitted changes`,
668
+ })
669
+ }
670
+ removed.push(checkoutPath)
671
+ }
672
+ if (options.dryRun) continue
646
673
 
647
674
  const result = yield* fs.runCommand(
648
675
  [
@@ -661,10 +688,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
661
688
  message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
662
689
  })
663
690
  }
664
- if (checkoutExists) removed.push(checkoutPath)
665
691
  }
666
692
 
667
- if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
693
+ if (
694
+ !options.dryRun &&
695
+ codeDirectoryExists &&
696
+ (yield* fs.isDirectory(codePath))
697
+ ) {
668
698
  const remaining = yield* fs.readDirectory(codePath)
669
699
  if (remaining.length > 0) {
670
700
  return yield* new WorktreeError({
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),
@@ -0,0 +1,18 @@
1
+ import { join } from "node:path"
2
+
3
+ const lifecycleManifestName = ".agency-lifecycle.json"
4
+
5
+ export const archivedEpicDirectory = (root: string, id: string) =>
6
+ join(root, "archive", "epics", id)
7
+
8
+ export const archivedTaskDirectory = (root: string, id: string) =>
9
+ join(root, "archive", "tasks", id)
10
+
11
+ export const archivedPhaseDirectory = (
12
+ root: string,
13
+ taskId: string,
14
+ id: string,
15
+ ) => join(archivedTaskDirectory(root, taskId), "phases", id)
16
+
17
+ export const lifecycleManifestPath = (directory: string) =>
18
+ join(directory, lifecycleManifestName)
@@ -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
+ }> {}