@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.
- package/README.md +62 -8
- package/cli.ts +66 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +28 -1
- package/skills/agency/SKILL.md +14 -3
- package/src/cli-parser.test.ts +81 -0
- package/src/cli-parser.ts +71 -0
- package/src/cli.test.ts +85 -0
- package/src/commands/claim.ts +97 -0
- package/src/commands/phase.ts +1 -1
- package/src/commands/task-phase.test.ts +4 -4
- package/src/commands/task.ts +32 -35
- package/src/commands/validate.ts +1 -6
- package/src/commands/work.test.ts +46 -12
- package/src/commands/work.ts +38 -19
- package/src/protocol.test.ts +25 -0
- package/src/protocol.ts +16 -0
- package/src/services/ClaimService.test.ts +270 -0
- package/src/services/ClaimService.ts +446 -0
- package/src/services/PhaseService.ts +13 -0
- package/src/services/TaskPhaseService.test.ts +10 -9
- package/src/services/TaskService.ts +11 -0
- package/src/test-utils.ts +2 -0
- package/src/utils/chooser.test.ts +108 -0
- package/src/utils/chooser.ts +222 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/document-revision.ts +2 -0
- package/src/workbase/frontmatter.ts +59 -53
- package/src/workbase/schemas.test.ts +39 -0
- package/src/workbase/schemas.ts +22 -0
- package/src/workbase/work-target.test.ts +10 -0
- package/src/workbase/work-target.ts +45 -35
- package/src/workbase/workbase-choice.ts +12 -37
|
@@ -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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
})
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
30
|
-
`agency
|
|
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/`.
|
|
@@ -12,65 +12,71 @@ interface ParsedFrontmatter {
|
|
|
12
12
|
readonly body: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
})
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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
|
})
|