@markjaquith/agency 2.49.0 → 2.51.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.
Files changed (50) hide show
  1. package/README.md +56 -9
  2. package/cli.ts +56 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +142 -38
  5. package/src/cli-parser.test.ts +81 -0
  6. package/src/cli-parser.ts +78 -1
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/review.ts +38 -0
  10. package/src/commands/task.ts +27 -5
  11. package/src/commands/vcs.test.ts +75 -0
  12. package/src/commands/vcs.ts +72 -0
  13. package/src/commands/work.test.ts +2 -0
  14. package/src/commands/work.ts +2 -2
  15. package/src/commands/worktree.ts +1 -1
  16. package/src/graph-schema.test.ts +1 -1
  17. package/src/graph-schema.ts +36 -13
  18. package/src/protocol.ts +1 -0
  19. package/src/services/ArchiveService.test.ts +2 -2
  20. package/src/services/ArchiveService.ts +24 -0
  21. package/src/services/ClaimService.ts +3 -2
  22. package/src/services/ContextService.ts +118 -17
  23. package/src/services/DoctorService.ts +58 -7
  24. package/src/services/GraphMutationService.ts +5 -0
  25. package/src/services/GraphService.ts +119 -54
  26. package/src/services/PhaseService.ts +196 -70
  27. package/src/services/PullRequestService.test.ts +2 -2
  28. package/src/services/PullRequestService.ts +37 -33
  29. package/src/services/ReadinessService.ts +7 -3
  30. package/src/services/RepositoryService.test.ts +31 -1
  31. package/src/services/RepositoryService.ts +63 -31
  32. package/src/services/ReviewService.test.ts +472 -0
  33. package/src/services/ReviewService.ts +427 -0
  34. package/src/services/SyncService.test.ts +69 -2
  35. package/src/services/SyncService.ts +161 -90
  36. package/src/services/TaskPhaseService.test.ts +80 -0
  37. package/src/services/TaskService.ts +82 -9
  38. package/src/services/VcsMigrationService.test.ts +211 -0
  39. package/src/services/VcsMigrationService.ts +816 -0
  40. package/src/services/VersionControlService.test.ts +100 -0
  41. package/src/services/VersionControlService.ts +479 -0
  42. package/src/services/WorkbaseService.ts +7 -2
  43. package/src/services/WorktreeService.test.ts +88 -17
  44. package/src/services/WorktreeService.ts +865 -329
  45. package/src/test-utils.ts +12 -0
  46. package/src/work-view.ts +14 -6
  47. package/src/workbase/AGENTS.md +2 -2
  48. package/src/workbase/schemas.test.ts +57 -0
  49. package/src/workbase/schemas.ts +57 -0
  50. package/src/workbase/version-control.ts +5 -0
package/src/test-utils.ts CHANGED
@@ -20,6 +20,13 @@ import { SyncService } from "./services/SyncService"
20
20
  import { ReadinessService } from "./services/ReadinessService"
21
21
  import { GraphMutationService } from "./services/GraphMutationService"
22
22
  import { DoctorService } from "./services/DoctorService"
23
+ import { ReviewService } from "./services/ReviewService"
24
+ import {
25
+ GitVersionControlService,
26
+ JjVersionControlService,
27
+ VersionControlService,
28
+ } from "./services/VersionControlService"
29
+ import { VcsMigrationService } from "./services/VcsMigrationService"
23
30
 
24
31
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
25
32
 
@@ -29,6 +36,10 @@ export const cleanupTempDir = (path: string) =>
29
36
  const TestLayer = Layer.mergeAll(
30
37
  FileSystemService.Default,
31
38
  WorkbaseService.Default,
39
+ GitVersionControlService.Default,
40
+ JjVersionControlService.Default,
41
+ VersionControlService.Default,
42
+ VcsMigrationService.Default,
32
43
  RepositoryService.Default,
33
44
  EpicService.Default,
34
45
  TaskService.Default,
@@ -44,6 +55,7 @@ const TestLayer = Layer.mergeAll(
44
55
  ReadinessService.Default,
45
56
  GraphMutationService.Default,
46
57
  DoctorService.Default,
58
+ ReviewService.Default,
47
59
  )
48
60
 
49
61
  export async function runTestEffect<A, E>(
package/src/work-view.ts CHANGED
@@ -79,7 +79,11 @@ const rowFor = (
79
79
  executions: readonly ExecutionNode[],
80
80
  ): WorkViewRow => {
81
81
  const branches = [
82
- ...new Set(executions.map((execution) => execution.data.branch)),
82
+ ...new Set(
83
+ executions.flatMap((execution) =>
84
+ "branch" in execution.data ? [execution.data.branch] : [],
85
+ ),
86
+ ),
83
87
  ]
84
88
  const parent =
85
89
  node.kind === "task"
@@ -105,16 +109,19 @@ const rowFor = (
105
109
  : branches.length === 1
106
110
  ? branches[0]!
107
111
  : "multiple",
108
- pr: aggregateLabel(executions, (execution) => Boolean(execution.data.pr), [
109
- "present",
110
- "absent",
111
- ]),
112
+ pr: aggregateLabel(
113
+ executions,
114
+ (execution) => "pr" in execution.data && Boolean(execution.data.pr),
115
+ ["present", "absent"],
116
+ ),
112
117
  worktree: aggregateLabel(
113
118
  executions,
114
119
  (execution) => execution.workspace?.materialized === true,
115
120
  ["materialized", "absent"],
116
121
  ),
117
- hasPr: executions.some((execution) => Boolean(execution.data.pr)),
122
+ hasPr: executions.some(
123
+ (execution) => "pr" in execution.data && Boolean(execution.data.pr),
124
+ ),
118
125
  }
119
126
  }
120
127
 
@@ -213,6 +220,7 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
213
220
  return executions.filter(
214
221
  (execution) =>
215
222
  execution.data.taskId === taskId &&
223
+ "phaseId" in execution.data &&
216
224
  execution.data.phaseId === phaseId,
217
225
  )
218
226
  }
@@ -1,8 +1,8 @@
1
1
  # Agency Workbase
2
2
 
3
3
  This directory is an Agency workbase. Epics, tasks, and phases are durable
4
- Markdown documents; repository aliases and generated Git worktrees provide code
5
- access.
4
+ Markdown documents; repository aliases and generated Git worktrees or jj
5
+ workspaces provide code access according to the workbase's `vcs` setting.
6
6
 
7
7
  ## Bootstrap
8
8
 
@@ -64,6 +64,63 @@ describe("portable repository declarations", () => {
64
64
  })
65
65
 
66
66
  describe("body-of-work descriptions", () => {
67
+ test("decodes review tasks strictly and rejects writable execution fields", () => {
68
+ const review = {
69
+ ticketUrl: null,
70
+ review: {
71
+ repo: "agency",
72
+ source: { kind: "branch", ref: "refs/heads/feature/review" },
73
+ commit: "a".repeat(40),
74
+ refreshedAt: "2026-07-23T12:00:00.000Z",
75
+ },
76
+ }
77
+ expect(
78
+ Schema.decodeUnknownSync(TaskFrontmatter, { onExcessProperty: "error" })(
79
+ review,
80
+ ),
81
+ ).toMatchObject({ status: "open", review: review.review })
82
+ for (const field of ["repo", "repos", "branch", "base", "pr"]) {
83
+ expect(() =>
84
+ Schema.decodeUnknownSync(TaskFrontmatter, {
85
+ onExcessProperty: "error",
86
+ })({ ...review, [field]: field === "repos" ? [] : "forbidden" }),
87
+ ).toThrow()
88
+ }
89
+ })
90
+
91
+ test("rejects inconsistent pull request source provenance", () => {
92
+ const source = {
93
+ kind: "pull-request",
94
+ provider: "github",
95
+ repository: "owner/repo",
96
+ identifier: "42",
97
+ url: "https://github.com/owner/repo/pull/42",
98
+ fetchRef: "refs/pull/42/head",
99
+ }
100
+ const review = {
101
+ ticketUrl: null,
102
+ review: {
103
+ repo: "agency",
104
+ source,
105
+ commit: "a".repeat(40),
106
+ refreshedAt: "2026-07-23T12:00:00.000Z",
107
+ },
108
+ }
109
+ expect(Schema.decodeUnknownSync(TaskFrontmatter)(review)).toBeDefined()
110
+ for (const inconsistent of [
111
+ { ...source, url: "https://github.com/owner/repo/pull/41" },
112
+ { ...source, repository: "other/repo" },
113
+ { ...source, fetchRef: "refs/pull/41/head" },
114
+ ]) {
115
+ expect(() =>
116
+ Schema.decodeUnknownSync(TaskFrontmatter)({
117
+ ...review,
118
+ review: { ...review.review, source: inconsistent },
119
+ }),
120
+ ).toThrow()
121
+ }
122
+ })
123
+
67
124
  test("accepts descriptions on epics, tasks, and phases", () => {
68
125
  const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
69
126
  ticketUrl: "https://example.com/epic",
@@ -43,6 +43,8 @@ const IsoTimestamp = NonEmptyString.pipe(
43
43
  Schema.pattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/),
44
44
  )
45
45
 
46
+ const GitCommit = Schema.String.pipe(Schema.pattern(/^[a-f0-9]{40}$/))
47
+
46
48
  const DocumentRevision = Schema.String.pipe(Schema.pattern(/^[a-f0-9]{64}$/))
47
49
 
48
50
  export const ClaimRecord = Schema.Struct({
@@ -94,6 +96,7 @@ const DeliveryProvider = Schema.Struct({
94
96
 
95
97
  export const WorkbaseConfig = Schema.Struct({
96
98
  version: Schema.Literal(2),
99
+ vcs: Schema.optional(Schema.Literal("git", "jj")),
97
100
  repositories: Schema.optional(
98
101
  Schema.Record({ key: RepositoryAlias, value: RepositoryDeclaration }),
99
102
  ),
@@ -172,9 +175,61 @@ const MultiPhaseTaskFrontmatter = Schema.Struct({
172
175
  phases: Schema.Array(Dependency),
173
176
  })
174
177
 
178
+ export const ReviewPullRequestSource = Schema.Struct({
179
+ kind: Schema.Literal("pull-request"),
180
+ provider: Schema.Literal("github"),
181
+ repository: NonEmptyString,
182
+ identifier: NonEmptyString.pipe(Schema.pattern(/^\d+$/)),
183
+ url: GitHubPullRequestUrl,
184
+ fetchRef: NonEmptyString,
185
+ }).pipe(
186
+ Schema.filter(
187
+ (source) =>
188
+ source.url ===
189
+ `https://github.com/${source.repository}/pull/${source.identifier}` &&
190
+ source.fetchRef === `refs/pull/${source.identifier}/head`,
191
+ {
192
+ message: () =>
193
+ "Pull request review source URL, repository, identifier, and fetch ref must agree",
194
+ },
195
+ ),
196
+ )
197
+
198
+ export const ReviewBranchSource = Schema.Struct({
199
+ kind: Schema.Literal("branch"),
200
+ ref: NonEmptyString.pipe(
201
+ Schema.pattern(
202
+ /^refs\/heads\/(?!HEAD$)(?!.*(?:\.\.|@\{|[ ~^:?*\[\\\]]))(?!.*\/\/)(?!.*(?:^|\/)\.)(?!.*\/$)(?!.*\.lock(?:\/|$))[A-Za-z0-9._\/-]+$/,
203
+ ),
204
+ ),
205
+ })
206
+
207
+ export const ReviewSource = Schema.Union(
208
+ ReviewPullRequestSource,
209
+ ReviewBranchSource,
210
+ )
211
+
212
+ export const ReviewRecord = Schema.Struct({
213
+ repo: RepositoryAlias,
214
+ source: ReviewSource,
215
+ commit: GitCommit,
216
+ refreshedAt: IsoTimestamp,
217
+ })
218
+
219
+ const ReviewTaskFrontmatter = Schema.Struct({
220
+ ticketUrl: Schema.NullOr(Url),
221
+ description: Description,
222
+ epic: Schema.optional(EntityId),
223
+ review: ReviewRecord,
224
+ status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
225
+ claim: Schema.optional(ClaimRecord),
226
+ completion: Schema.optional(CompletionRecord),
227
+ })
228
+
175
229
  export const TaskFrontmatter = Schema.Union(
176
230
  SinglePhaseTaskFrontmatter,
177
231
  MultiPhaseTaskFrontmatter,
232
+ ReviewTaskFrontmatter,
178
233
  )
179
234
 
180
235
  export const PhaseFrontmatter = Schema.Struct({
@@ -195,6 +250,8 @@ export type RepositoryDeclaration = Schema.Schema.Type<
195
250
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
196
251
  export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
197
252
  export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
253
+ export type ReviewSource = Schema.Schema.Type<typeof ReviewSource>
254
+ export type ReviewRecord = Schema.Schema.Type<typeof ReviewRecord>
198
255
  export type CompletionRecord = Schema.Schema.Type<typeof CompletionRecord>
199
256
  export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
200
257
  export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
@@ -0,0 +1,5 @@
1
+ export type VersionControlKind = "git" | "jj"
2
+
3
+ export const preferredVersionControl = (
4
+ which: (executable: string) => string | null = Bun.which,
5
+ ): VersionControlKind => (which("jj") ? "jj" : "git")