@markjaquith/agency 2.13.0 → 2.15.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.
@@ -193,6 +193,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
193
193
  branch: task.data.branch,
194
194
  base: task.data.base,
195
195
  pr: task.data.pr,
196
+ status: task.data.status,
197
+ ...(task.data.claim ? { claim: task.data.claim } : {}),
196
198
  })
197
199
  const firstTitle = firstPhaseId!
198
200
  .split("-")
@@ -340,7 +342,18 @@ export class PhaseService extends Effect.Service<PhaseService>()(
340
342
  const fs = yield* FileSystemService
341
343
  const service = yield* PhaseService
342
344
  const validStatus = yield* decodeStatus(status)
345
+ if (validStatus === "working" || validStatus === "delegated") {
346
+ return yield* new PhaseError({
347
+ message:
348
+ "Active work and delegation require explicit ownership; use 'agency claim'",
349
+ })
350
+ }
343
351
  const record = yield* service.show(taskId, id, startPath)
352
+ if (record.data.claim?.state === "active") {
353
+ return yield* new PhaseError({
354
+ message: `Phase '${id}' has an active claim; use agency release or agency finish`,
355
+ })
356
+ }
344
357
  if (!canTransitionStatus(record.data.status, validStatus)) {
345
358
  return yield* new PhaseError({
346
359
  message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
@@ -234,16 +234,17 @@ describe("task and phase services", () => {
234
234
  ),
235
235
  )
236
236
  expect(createdTask.content).toContain("status: open")
237
- const task = await runTestEffect(
238
- TaskService.pipe(
239
- Effect.flatMap((service) =>
240
- service.setStatus("single-status", "delegated", root),
237
+ for (const status of ["working", "delegated"]) {
238
+ await expect(
239
+ runTestEffect(
240
+ TaskService.pipe(
241
+ Effect.flatMap((service) =>
242
+ service.setStatus("single-status", status, root),
243
+ ),
244
+ ),
241
245
  ),
242
- ),
243
- )
244
- expect(task.data.status).toBe("delegated")
245
- expect(task.content).toContain("status: delegated")
246
- expect(task.content).toContain("Describe the task outcome.")
246
+ ).rejects.toThrow("require explicit ownership")
247
+ }
247
248
  await runTestEffect(
248
249
  TaskService.pipe(
249
250
  Effect.flatMap((service) =>
@@ -207,12 +207,23 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
207
207
  const fs = yield* FileSystemService
208
208
  const service = yield* TaskService
209
209
  const validStatus = yield* decodeStatus(status)
210
+ if (validStatus === "working" || validStatus === "delegated") {
211
+ return yield* new TaskError({
212
+ message:
213
+ "Active work and delegation require explicit ownership; use 'agency claim'",
214
+ })
215
+ }
210
216
  const record = yield* service.show(id, startPath)
211
217
  if ("phases" in record.data) {
212
218
  return yield* new TaskError({
213
219
  message: `Task '${id}' has multiple phases; set status on a phase instead`,
214
220
  })
215
221
  }
222
+ if (record.data.claim?.state === "active") {
223
+ return yield* new TaskError({
224
+ message: `Task '${id}' has an active claim; use agency release or agency finish`,
225
+ })
226
+ }
216
227
  if (!canTransitionStatus(record.data.status, validStatus)) {
217
228
  return yield* new TaskError({
218
229
  message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
package/src/test-utils.ts CHANGED
@@ -15,6 +15,7 @@ import { ArchiveService } from "./services/ArchiveService"
15
15
  import { IntegrationService } from "./services/IntegrationService"
16
16
  import { ContextService } from "./services/ContextService"
17
17
  import { GraphService } from "./services/GraphService"
18
+ import { ClaimService } from "./services/ClaimService"
18
19
 
19
20
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
20
21
 
@@ -34,6 +35,7 @@ const TestLayer = Layer.mergeAll(
34
35
  IntegrationService.Default,
35
36
  ContextService.Default,
36
37
  GraphService.Default,
38
+ ClaimService.Default,
37
39
  )
38
40
 
39
41
  export async function runTestEffect<A, E>(
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { choose, type ChooserIO } from "./chooser"
4
+
5
+ const choices = [
6
+ { key: "first-key", label: "\x1b[32mFirst\x1b[0m", value: 1 },
7
+ { key: "second key", label: "Second", value: 2 },
8
+ ]
9
+
10
+ const createIO = (
11
+ overrides: Partial<ChooserIO> = {},
12
+ ): ChooserIO & { readonly writes: string[]; readonly inputs: string[] } => {
13
+ const writes: string[] = []
14
+ const inputs: string[] = []
15
+ return {
16
+ inputIsTTY: true,
17
+ outputIsTTY: true,
18
+ color: false,
19
+ write: (message) => writes.push(message),
20
+ question: async () => "1",
21
+ run: async (_command, input) => {
22
+ inputs.push(input)
23
+ return { exitCode: 0, stdout: "first-key\n" }
24
+ },
25
+ ...overrides,
26
+ writes,
27
+ inputs,
28
+ }
29
+ }
30
+
31
+ describe("chooser", () => {
32
+ test("offers a plain-text numbered chooser on a TTY", async () => {
33
+ const io = createIO({ question: async () => "2" })
34
+
35
+ const result = await Effect.runPromise(
36
+ choose("Pick one", choices, undefined, io),
37
+ )
38
+
39
+ expect(result).toBe(2)
40
+ expect(io.writes.join("")).toBe("Pick one\n 1. First\n 2. Second\n")
41
+ })
42
+
43
+ test("preserves colors when enabled", async () => {
44
+ const io = createIO({ color: true })
45
+
46
+ await Effect.runPromise(choose("Pick one", choices, undefined, io))
47
+
48
+ expect(io.writes.join("")).toContain("\x1b[32mFirst\x1b[0m")
49
+ })
50
+
51
+ test("passes generic records to an external argv command", async () => {
52
+ const io = createIO()
53
+
54
+ const result = await Effect.runPromise(
55
+ choose("Pick one", choices, ["custom-chooser", "--flag"], io),
56
+ )
57
+
58
+ expect(result).toBe(1)
59
+ expect(io.inputs).toEqual(["first-key\tFirst\nsecond key\tSecond\n"])
60
+ })
61
+
62
+ test("accepts a selected record from fzf or gum", async () => {
63
+ const io = createIO({
64
+ run: async () => ({ exitCode: 0, stdout: "second key\tSecond\n" }),
65
+ })
66
+
67
+ expect(
68
+ await Effect.runPromise(choose("Pick", choices, ["gum", "filter"], io)),
69
+ ).toBe(2)
70
+ })
71
+
72
+ test("treats native and external cancellation as no selection", async () => {
73
+ const native = createIO({ question: async () => "q" })
74
+ const external = createIO({
75
+ run: async () => ({ exitCode: 130, stdout: "" }),
76
+ })
77
+
78
+ expect(
79
+ await Effect.runPromise(choose("Pick", choices, undefined, native)),
80
+ ).toBeNull()
81
+ expect(
82
+ await Effect.runPromise(choose("Pick", choices, ["chooser"], external)),
83
+ ).toBeNull()
84
+ })
85
+
86
+ test("uses one typed error for unavailable input and invalid keys", async () => {
87
+ const nonTTY = createIO({ inputIsTTY: false })
88
+ const unknownKey = createIO({
89
+ run: async () => ({ exitCode: 0, stdout: "missing\n" }),
90
+ })
91
+
92
+ const unavailable = await Effect.runPromise(
93
+ Effect.flip(choose("Pick", choices, undefined, nonTTY)),
94
+ )
95
+ const invalid = await Effect.runPromise(
96
+ Effect.flip(choose("Pick", choices, ["chooser"], unknownKey)),
97
+ )
98
+
99
+ expect(unavailable).toMatchObject({
100
+ name: "ChooserError",
101
+ reason: "input-unavailable",
102
+ })
103
+ expect(invalid).toMatchObject({
104
+ name: "ChooserError",
105
+ reason: "invalid-selection",
106
+ })
107
+ })
108
+ })
@@ -0,0 +1,222 @@
1
+ import { Effect } from "effect"
2
+ import { createInterface } from "node:readline/promises"
3
+
4
+ export interface Choice<T> {
5
+ readonly key: string
6
+ readonly label: string
7
+ readonly plainLabel?: string
8
+ readonly value: T
9
+ }
10
+
11
+ export type ChooserErrorReason =
12
+ | "invalid-choices"
13
+ | "input-unavailable"
14
+ | "invalid-selection"
15
+ | "command-failed"
16
+
17
+ export class ChooserError extends Error {
18
+ override readonly name = "ChooserError"
19
+
20
+ constructor(
21
+ readonly reason: ChooserErrorReason,
22
+ message: string,
23
+ options?: ErrorOptions,
24
+ ) {
25
+ super(message, options)
26
+ }
27
+ }
28
+
29
+ interface ExternalResult {
30
+ readonly exitCode: number
31
+ readonly stdout: string
32
+ }
33
+
34
+ export interface ChooserIO {
35
+ readonly inputIsTTY: boolean
36
+ readonly outputIsTTY: boolean
37
+ readonly color: boolean
38
+ readonly write: (message: string) => void
39
+ readonly question: (prompt: string) => Promise<string>
40
+ readonly run: (
41
+ command: readonly string[],
42
+ input: string,
43
+ ) => Promise<ExternalResult>
44
+ }
45
+
46
+ const stripAnsi = (value: string) =>
47
+ value.replace(
48
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g,
49
+ "",
50
+ )
51
+
52
+ const defaultIO = (): ChooserIO => ({
53
+ inputIsTTY: Boolean(process.stdin.isTTY),
54
+ outputIsTTY: Boolean(process.stderr.isTTY),
55
+ color:
56
+ Boolean(process.stderr.isTTY) &&
57
+ process.env.NO_COLOR === undefined &&
58
+ process.env.TERM !== "dumb",
59
+ write: (message) => process.stderr.write(message),
60
+ question: async (prompt) => {
61
+ const input = createInterface({
62
+ input: process.stdin,
63
+ output: process.stderr,
64
+ })
65
+ try {
66
+ return await input.question(prompt)
67
+ } finally {
68
+ input.close()
69
+ }
70
+ },
71
+ run: async (command, input) => {
72
+ const child = Bun.spawn([...command], {
73
+ stdin: new Blob([input]),
74
+ stdout: "pipe",
75
+ stderr: "inherit",
76
+ })
77
+ const [exitCode, stdout] = await Promise.all([
78
+ child.exited,
79
+ new Response(child.stdout).text(),
80
+ ])
81
+ return { exitCode, stdout }
82
+ },
83
+ })
84
+
85
+ const displayLabel = (choice: Choice<unknown>, color: boolean) => {
86
+ const normalized = (
87
+ color ? choice.label : (choice.plainLabel ?? choice.label)
88
+ ).replace(/[\r\n]+/g, " ")
89
+ return color ? normalized : stripAnsi(normalized)
90
+ }
91
+
92
+ const validateChoices = <T>(choices: readonly Choice<T>[]) => {
93
+ if (choices.length === 0) {
94
+ throw new ChooserError(
95
+ "invalid-choices",
96
+ "Cannot choose from an empty list",
97
+ )
98
+ }
99
+ const keys = new Set<string>()
100
+ for (const choice of choices) {
101
+ if (!choice.key || /[\t\r\n]/.test(choice.key)) {
102
+ throw new ChooserError(
103
+ "invalid-choices",
104
+ "Chooser keys must be non-empty and cannot contain tabs or newlines",
105
+ )
106
+ }
107
+ if (keys.has(choice.key)) {
108
+ throw new ChooserError(
109
+ "invalid-choices",
110
+ `Duplicate chooser key: ${choice.key}`,
111
+ )
112
+ }
113
+ keys.add(choice.key)
114
+ }
115
+ }
116
+
117
+ const selectedChoice = <T>(key: string, choices: readonly Choice<T>[]) => {
118
+ const selected = choices.find((choice) => choice.key === key)
119
+ if (!selected) {
120
+ throw new ChooserError(
121
+ "invalid-selection",
122
+ `Chooser returned an unknown key: ${key}`,
123
+ )
124
+ }
125
+ return selected.value
126
+ }
127
+
128
+ const externalChoice = async <T>(
129
+ choices: readonly Choice<T>[],
130
+ command: readonly string[],
131
+ io: ChooserIO,
132
+ ) => {
133
+ let result: ExternalResult
134
+ try {
135
+ const records = `${choices
136
+ .map((choice) => `${choice.key}\t${displayLabel(choice, io.color)}`)
137
+ .join("\n")}\n`
138
+ result = await io.run(command, records)
139
+ } catch (cause) {
140
+ throw new ChooserError(
141
+ "command-failed",
142
+ `Failed to run chooser command: ${command.join(" ")}`,
143
+ { cause },
144
+ )
145
+ }
146
+ if (result.exitCode === 1 || result.exitCode === 130) return null
147
+ if (result.exitCode !== 0) {
148
+ throw new ChooserError(
149
+ "command-failed",
150
+ `Chooser command exited with code ${result.exitCode}`,
151
+ )
152
+ }
153
+ const output = result.stdout.replace(/\r?\n$/, "")
154
+ if (!output) return null
155
+ if (/[\r\n]/.test(output)) {
156
+ throw new ChooserError(
157
+ "invalid-selection",
158
+ "Chooser command returned more than one key",
159
+ )
160
+ }
161
+ const key = output.split("\t", 1)[0] ?? ""
162
+ return selectedChoice(key, choices)
163
+ }
164
+
165
+ const nativeChoice = async <T>(
166
+ choices: readonly Choice<T>[],
167
+ prompt: string,
168
+ io: ChooserIO,
169
+ ) => {
170
+ if (!io.inputIsTTY || !io.outputIsTTY) {
171
+ throw new ChooserError(
172
+ "input-unavailable",
173
+ "Interactive selection requires a terminal; provide an explicit value or use --no-input",
174
+ )
175
+ }
176
+ io.write(`${prompt}\n`)
177
+ for (const [index, choice] of choices.entries()) {
178
+ io.write(` ${index + 1}. ${displayLabel(choice, io.color)}\n`)
179
+ }
180
+ let answer: string
181
+ try {
182
+ answer = (
183
+ await io.question(`Select [1-${choices.length}] (q to cancel): `)
184
+ ).trim()
185
+ } catch (cause) {
186
+ if (cause instanceof Error && cause.name === "AbortError") return null
187
+ throw new ChooserError("input-unavailable", "Failed to read selection", {
188
+ cause,
189
+ })
190
+ }
191
+ if (!answer || answer.toLowerCase() === "q") return null
192
+ if (!/^\d+$/.test(answer)) {
193
+ throw new ChooserError("invalid-selection", `Invalid selection: ${answer}`)
194
+ }
195
+ const selected = choices[Number.parseInt(answer, 10) - 1]
196
+ if (!selected) {
197
+ throw new ChooserError(
198
+ "invalid-selection",
199
+ `Selection must be between 1 and ${choices.length}`,
200
+ )
201
+ }
202
+ return selected.value
203
+ }
204
+
205
+ export const choose = <T>(
206
+ prompt: string,
207
+ choices: readonly Choice<T>[],
208
+ command?: readonly string[],
209
+ io: ChooserIO = defaultIO(),
210
+ ): Effect.Effect<T | null, ChooserError> =>
211
+ Effect.tryPromise({
212
+ try: async () => {
213
+ validateChoices(choices)
214
+ return command
215
+ ? await externalChoice(choices, command, io)
216
+ : await nativeChoice(choices, prompt, io)
217
+ },
218
+ catch: (cause) =>
219
+ cause instanceof ChooserError
220
+ ? cause
221
+ : new ChooserError("input-unavailable", "Selection failed", { cause }),
222
+ })
@@ -26,8 +26,8 @@ field. Repositories listed in plural `repos` are read-only references.
26
26
  `agency integration sync` to update managed agent files explicitly.
27
27
  - Keep task-level decisions in `TASK.md` and phase-specific delivery context in
28
28
  `PHASE.md`.
29
- - Keep execution-unit `status` current with `agency task status` or
30
- `agency phase status`; `agency work` marks launched work as `working`.
29
+ - Coordinate execution ownership with `agency claim`, `agency release`, and
30
+ `agency finish`; `agency work` claims execution units before launch.
31
31
  - Do not manually create, move, or remove worktrees under `code/`.
32
32
  - Use `agency archive`, rather than moving work item folders manually.
33
33
  - Do not edit bare repositories or repository symlinks under `repos/`.
@@ -0,0 +1,2 @@
1
+ export const documentRevision = (content: string) =>
2
+ new Bun.CryptoHasher("sha256").update(content).digest("hex")
@@ -12,65 +12,71 @@ interface ParsedFrontmatter {
12
12
  readonly body: string
13
13
  }
14
14
 
15
- export const parseFrontmatter = (content: string, path: string) =>
16
- Effect.try({
17
- try: (): ParsedFrontmatter => {
18
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
19
- if (!match) {
20
- throw new Error("Markdown file must begin with YAML frontmatter")
21
- }
15
+ export const parseFrontmatterSync = (
16
+ content: string,
17
+ path: string,
18
+ ): ParsedFrontmatter => {
19
+ try {
20
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
21
+ if (!match) {
22
+ throw new Error("Markdown file must begin with YAML frontmatter")
23
+ }
24
+
25
+ const document = parseDocument(match[1]!, {
26
+ customTags: [],
27
+ merge: false,
28
+ schema: "core",
29
+ strict: true,
30
+ uniqueKeys: true,
31
+ version: "1.2",
32
+ })
22
33
 
23
- const document = parseDocument(match[1]!, {
24
- customTags: [],
25
- merge: false,
26
- schema: "core",
27
- strict: true,
28
- uniqueKeys: true,
29
- version: "1.2",
30
- })
34
+ const parseMessages = [...document.errors, ...document.warnings]
35
+ if (parseMessages.length > 0) {
36
+ throw new Error(parseMessages.map((error) => error.message).join("; "))
37
+ }
31
38
 
32
- const parseMessages = [...document.errors, ...document.warnings]
33
- if (parseMessages.length > 0) {
34
- throw new Error(parseMessages.map((error) => error.message).join("; "))
35
- }
39
+ let unsupportedFeature: string | null = null
40
+ visit(document, {
41
+ Alias: () => {
42
+ unsupportedFeature = "YAML aliases are not supported"
43
+ },
44
+ Node: (_key, node) => {
45
+ if (node.anchor) {
46
+ unsupportedFeature = "YAML anchors are not supported"
47
+ } else if (node.tag && !node.tag.startsWith("tag:yaml.org,2002:")) {
48
+ unsupportedFeature = "Custom YAML tags are not supported"
49
+ }
50
+ },
51
+ })
36
52
 
37
- let unsupportedFeature: string | null = null
38
- visit(document, {
39
- Alias: () => {
40
- unsupportedFeature = "YAML aliases are not supported"
41
- },
42
- Node: (_key, node) => {
43
- if (node.anchor) {
44
- unsupportedFeature = "YAML anchors are not supported"
45
- } else if (node.tag && !node.tag.startsWith("tag:yaml.org,2002:")) {
46
- unsupportedFeature = "Custom YAML tags are not supported"
47
- }
48
- },
49
- })
53
+ if (unsupportedFeature) {
54
+ throw new Error(unsupportedFeature)
55
+ }
50
56
 
51
- if (unsupportedFeature) {
52
- throw new Error(unsupportedFeature)
53
- }
57
+ const data = document.toJS({ maxAliasCount: 0 })
58
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
59
+ throw new Error("YAML frontmatter must be a mapping")
60
+ }
54
61
 
55
- const data = document.toJS({ maxAliasCount: 0 })
56
- if (data === null || typeof data !== "object" || Array.isArray(data)) {
57
- throw new Error("YAML frontmatter must be a mapping")
58
- }
62
+ return {
63
+ data,
64
+ body: content.slice(match[0].length),
65
+ }
66
+ } catch (cause) {
67
+ throw new FrontmatterParseError({
68
+ path,
69
+ message:
70
+ cause instanceof Error ? cause.message : "Failed to parse frontmatter",
71
+ cause,
72
+ })
73
+ }
74
+ }
59
75
 
60
- return {
61
- data,
62
- body: content.slice(match[0].length),
63
- }
64
- },
65
- catch: (cause) =>
66
- new FrontmatterParseError({
67
- path,
68
- message:
69
- cause instanceof Error
70
- ? cause.message
71
- : "Failed to parse frontmatter",
72
- cause,
73
- }),
76
+ export const parseFrontmatter = (content: string, path: string) =>
77
+ Effect.try({
78
+ try: () => parseFrontmatterSync(content, path),
79
+ catch: (error) => error as FrontmatterParseError,
74
80
  })
75
81
 
76
82
  export const formatMarkdownDocument = (data: object, body: string) =>
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
2
2
  import { Schema } from "@effect/schema"
3
3
  import {
4
4
  EntityId,
5
+ ClaimRecord,
5
6
  EpicFrontmatter,
6
7
  PhaseFrontmatter,
7
8
  TaskFrontmatter,
@@ -154,6 +155,32 @@ describe("work status", () => {
154
155
  })
155
156
  })
156
157
 
158
+ describe("claim records", () => {
159
+ const record = {
160
+ claimant: "orchestrator",
161
+ runner: "agent",
162
+ sessionId: "job-1",
163
+ startedAt: "2026-07-17T12:00:00.000Z",
164
+ targetRevision: "0".repeat(64),
165
+ expiresAt: "2026-07-17T13:00:00.000Z",
166
+ state: "active" as const,
167
+ }
168
+
169
+ test("accepts explicit ownership and revision metadata", () => {
170
+ expect(Schema.decodeUnknownSync(ClaimRecord)(record)).toEqual(record)
171
+ })
172
+
173
+ test("rejects malformed timestamps, revisions, and empty identities", () => {
174
+ for (const invalid of [
175
+ { ...record, claimant: "" },
176
+ { ...record, startedAt: "today" },
177
+ { ...record, targetRevision: "abc" },
178
+ ]) {
179
+ expect(() => Schema.decodeUnknownSync(ClaimRecord)(invalid)).toThrow()
180
+ }
181
+ })
182
+ })
183
+
157
184
  describe("workbase registry", () => {
158
185
  test("accepts registered paths", () => {
159
186
  expect(
@@ -257,4 +284,16 @@ describe("schema boundaries", () => {
257
284
  ).not.toThrow()
258
285
  }
259
286
  })
287
+
288
+ test("accepts a configured chooser argv", () => {
289
+ expect(
290
+ Schema.decodeUnknownSync(WorkbaseConfig)({
291
+ version: 2,
292
+ chooserCommand: ["fzf", "--accept-nth=1"],
293
+ }),
294
+ ).toEqual({
295
+ version: 2,
296
+ chooserCommand: ["fzf", "--accept-nth=1"],
297
+ })
298
+ })
260
299
  })
@@ -23,6 +23,25 @@ export const WorkStatus = Schema.Literal(
23
23
  "dropped",
24
24
  )
25
25
 
26
+ const IsoTimestamp = NonEmptyString.pipe(
27
+ Schema.pattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/),
28
+ )
29
+
30
+ const DocumentRevision = Schema.String.pipe(Schema.pattern(/^[a-f0-9]{64}$/))
31
+
32
+ export const ClaimRecord = Schema.Struct({
33
+ claimant: NonEmptyString,
34
+ runner: NonEmptyString,
35
+ sessionId: NonEmptyString,
36
+ startedAt: IsoTimestamp,
37
+ targetRevision: DocumentRevision,
38
+ expiresAt: Schema.optional(IsoTimestamp),
39
+ state: Schema.Literal("active", "released", "finished"),
40
+ releasedAt: Schema.optional(IsoTimestamp),
41
+ finishedAt: Schema.optional(IsoTimestamp),
42
+ outcome: Schema.optional(Schema.Literal("done", "dropped")),
43
+ })
44
+
26
45
  const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
27
46
 
28
47
  const GitHubPullRequestUrl = NonEmptyString.pipe(
@@ -31,6 +50,7 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
31
50
 
32
51
  export const WorkbaseConfig = Schema.Struct({
33
52
  version: Schema.Literal(2),
53
+ chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
34
54
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
35
55
  })
36
56
 
@@ -51,6 +71,7 @@ const ExecutionUnit = {
51
71
  base: NonEmptyString,
52
72
  pr: Schema.NullOr(GitHubPullRequestUrl),
53
73
  status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
74
+ claim: Schema.optional(ClaimRecord),
54
75
  }
55
76
 
56
77
  export const EpicFrontmatter = Schema.Struct({
@@ -89,6 +110,7 @@ export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
89
110
  export type Dependency = Schema.Schema.Type<typeof Dependency>
90
111
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
91
112
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
113
+ export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
92
114
  export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
93
115
  export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
94
116
  export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
@@ -80,5 +80,15 @@ describe("work target choices", () => {
80
80
  "task",
81
81
  "task",
82
82
  ])
83
+ expect(choices.map((choice) => choice.plainLabel)).toEqual([
84
+ "epic delivery - Ship the release",
85
+ " task multi",
86
+ " [working] phase build",
87
+ " [done] phase verify",
88
+ " [dropped] phase unlisted",
89
+ " [done] task single",
90
+ "[open] task standalone - Independent work",
91
+ "[delegated] task delegated",
92
+ ])
83
93
  })
84
94
  })