@markjaquith/agency 2.13.0 → 2.14.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 +39 -7
- 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 +1 -1
- package/src/commands/work.test.ts +26 -0
- package/src/commands/work.ts +34 -5
- 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/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 +27 -0
- package/src/workbase/schemas.ts +21 -0
|
@@ -0,0 +1,446 @@
|
|
|
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 } from "node:path"
|
|
13
|
+
import { PhaseService } from "./PhaseService"
|
|
14
|
+
import { TaskService } from "./TaskService"
|
|
15
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
16
|
+
import { FileSystemService } from "./FileSystemService"
|
|
17
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
18
|
+
import {
|
|
19
|
+
formatMarkdownDocument,
|
|
20
|
+
parseFrontmatterSync,
|
|
21
|
+
} from "../workbase/frontmatter"
|
|
22
|
+
import {
|
|
23
|
+
PhaseFrontmatter,
|
|
24
|
+
TaskFrontmatter,
|
|
25
|
+
type ClaimRecord,
|
|
26
|
+
type PhaseFrontmatter as PhaseData,
|
|
27
|
+
type TaskFrontmatter as TaskData,
|
|
28
|
+
} from "../workbase/schemas"
|
|
29
|
+
|
|
30
|
+
class ClaimError extends Data.TaggedError("ClaimError")<{
|
|
31
|
+
readonly message: string
|
|
32
|
+
readonly target?: string
|
|
33
|
+
}> {}
|
|
34
|
+
|
|
35
|
+
class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
|
|
36
|
+
readonly message: string
|
|
37
|
+
readonly target: string
|
|
38
|
+
readonly expectedRevision: string
|
|
39
|
+
readonly actualRevision: string
|
|
40
|
+
readonly claim?: ClaimRecord
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
|
|
44
|
+
readonly message: string
|
|
45
|
+
readonly target: string
|
|
46
|
+
readonly currentRevision: string
|
|
47
|
+
readonly claim?: ClaimRecord
|
|
48
|
+
readonly legacyStatus?: "working" | "delegated"
|
|
49
|
+
}> {}
|
|
50
|
+
|
|
51
|
+
class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
|
|
52
|
+
readonly message: string
|
|
53
|
+
readonly target: string
|
|
54
|
+
readonly currentRevision: string
|
|
55
|
+
readonly sessionId: string
|
|
56
|
+
readonly claim?: ClaimRecord
|
|
57
|
+
}> {}
|
|
58
|
+
|
|
59
|
+
interface ClaimTarget {
|
|
60
|
+
readonly kind: "task" | "phase"
|
|
61
|
+
readonly taskId: string
|
|
62
|
+
readonly phaseId?: string
|
|
63
|
+
readonly path: string
|
|
64
|
+
readonly label: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ClaimInput {
|
|
68
|
+
readonly taskId: string
|
|
69
|
+
readonly phaseId?: string
|
|
70
|
+
readonly claimant: string
|
|
71
|
+
readonly runner: string
|
|
72
|
+
readonly sessionId: string
|
|
73
|
+
readonly revision: string
|
|
74
|
+
readonly expiresAt?: string
|
|
75
|
+
readonly now?: Date
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface OwnedClaimInput {
|
|
79
|
+
readonly taskId: string
|
|
80
|
+
readonly phaseId?: string
|
|
81
|
+
readonly sessionId: string
|
|
82
|
+
readonly revision: string
|
|
83
|
+
readonly now?: Date
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface FinishInput extends OwnedClaimInput {
|
|
87
|
+
readonly outcome: "done" | "dropped"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
91
|
+
type ExecutionData = SingleTaskData | PhaseData
|
|
92
|
+
|
|
93
|
+
const isTaggedClaimError = (
|
|
94
|
+
error: unknown,
|
|
95
|
+
): error is
|
|
96
|
+
| ClaimError
|
|
97
|
+
| RevisionConflictError
|
|
98
|
+
| ClaimConflictError
|
|
99
|
+
| ClaimOwnershipError =>
|
|
100
|
+
typeof error === "object" &&
|
|
101
|
+
error !== null &&
|
|
102
|
+
"_tag" in error &&
|
|
103
|
+
typeof error._tag === "string" &&
|
|
104
|
+
[
|
|
105
|
+
"ClaimError",
|
|
106
|
+
"RevisionConflictError",
|
|
107
|
+
"ClaimConflictError",
|
|
108
|
+
"ClaimOwnershipError",
|
|
109
|
+
].includes(error._tag)
|
|
110
|
+
|
|
111
|
+
const decodeExecution = (target: ClaimTarget, input: unknown) => {
|
|
112
|
+
const schema: Schema.Schema<any> =
|
|
113
|
+
target.kind === "task" ? TaskFrontmatter : PhaseFrontmatter
|
|
114
|
+
const result = Schema.decodeUnknownEither(schema, {
|
|
115
|
+
errors: "all",
|
|
116
|
+
onExcessProperty: "error",
|
|
117
|
+
})(input)
|
|
118
|
+
if (Either.isLeft(result)) {
|
|
119
|
+
throw new ClaimError({
|
|
120
|
+
target: target.label,
|
|
121
|
+
message: TreeFormatter.formatErrorSync(result.left),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
if (target.kind === "task" && "phases" in result.right) {
|
|
125
|
+
throw new ClaimError({
|
|
126
|
+
target: target.label,
|
|
127
|
+
message: `Task '${target.taskId}' has multiple phases; claim a phase instead`,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return result.right as ExecutionData
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const assertRevision = (revision: string) => {
|
|
134
|
+
if (!/^[a-f0-9]{64}$/.test(revision)) {
|
|
135
|
+
throw new ClaimError({
|
|
136
|
+
message: "Revision must be a 64-character SHA-256 hash",
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const assertIdentity = (label: string, value: string) => {
|
|
142
|
+
if (!value.trim())
|
|
143
|
+
throw new ClaimError({ message: `${label} must not be empty` })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const isIsoTimestamp = (value: string) =>
|
|
147
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
|
|
148
|
+
Number.isFinite(Date.parse(value))
|
|
149
|
+
|
|
150
|
+
const isUnexpired = (claim: ClaimRecord, now: Date) =>
|
|
151
|
+
claim.state === "active" &&
|
|
152
|
+
(claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
|
|
153
|
+
|
|
154
|
+
const acquireLock = async (path: string) => {
|
|
155
|
+
const lockPath = `${path}.claim.lock`
|
|
156
|
+
for (let attempt = 0; attempt < 1_750; attempt += 1) {
|
|
157
|
+
try {
|
|
158
|
+
const handle = await open(lockPath, "wx")
|
|
159
|
+
return { handle, lockPath }
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (
|
|
162
|
+
!(error instanceof Error) ||
|
|
163
|
+
!("code" in error) ||
|
|
164
|
+
error.code !== "EEXIST"
|
|
165
|
+
) {
|
|
166
|
+
throw error
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const lock = await stat(lockPath)
|
|
170
|
+
if (Date.now() - lock.mtimeMs > 30_000) await unlink(lockPath)
|
|
171
|
+
} catch {
|
|
172
|
+
// Another process released the lock between checks.
|
|
173
|
+
}
|
|
174
|
+
await Bun.sleep(20)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new ClaimError({ message: `Timed out waiting to update ${path}` })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const updateAtomically = async <T>(
|
|
181
|
+
target: ClaimTarget,
|
|
182
|
+
expectedRevision: string,
|
|
183
|
+
update: (
|
|
184
|
+
data: ExecutionData,
|
|
185
|
+
now: Date,
|
|
186
|
+
) => T & {
|
|
187
|
+
readonly data: ExecutionData
|
|
188
|
+
},
|
|
189
|
+
now: Date,
|
|
190
|
+
) => {
|
|
191
|
+
assertRevision(expectedRevision)
|
|
192
|
+
const { handle, lockPath } = await acquireLock(target.path)
|
|
193
|
+
let temporaryPath: string | undefined
|
|
194
|
+
try {
|
|
195
|
+
const content = await readFile(target.path, "utf8")
|
|
196
|
+
const actualRevision = documentRevision(content)
|
|
197
|
+
const parsed = parseFrontmatterSync(content, target.path)
|
|
198
|
+
const current = decodeExecution(target, parsed.data)
|
|
199
|
+
if (actualRevision !== expectedRevision) {
|
|
200
|
+
throw new RevisionConflictError({
|
|
201
|
+
target: target.label,
|
|
202
|
+
expectedRevision,
|
|
203
|
+
actualRevision,
|
|
204
|
+
claim: current.claim,
|
|
205
|
+
message: `Revision conflict for ${target.label}`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const result = update(current, now)
|
|
209
|
+
const updatedContent = formatMarkdownDocument(result.data, parsed.body)
|
|
210
|
+
temporaryPath = join(
|
|
211
|
+
dirname(target.path),
|
|
212
|
+
`.${basename(target.path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
213
|
+
)
|
|
214
|
+
await writeFile(temporaryPath, updatedContent, { flag: "wx" })
|
|
215
|
+
await rename(temporaryPath, target.path)
|
|
216
|
+
temporaryPath = undefined
|
|
217
|
+
return {
|
|
218
|
+
...result,
|
|
219
|
+
target: target.label,
|
|
220
|
+
previousRevision: actualRevision,
|
|
221
|
+
revision: documentRevision(updatedContent),
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
|
|
225
|
+
await handle.close().catch(() => undefined)
|
|
226
|
+
await unlink(lockPath).catch(() => undefined)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const operation = <T>(run: () => Promise<T>) =>
|
|
231
|
+
Effect.tryPromise({
|
|
232
|
+
try: run,
|
|
233
|
+
catch: (error) =>
|
|
234
|
+
isTaggedClaimError(error)
|
|
235
|
+
? error
|
|
236
|
+
: new ClaimError({
|
|
237
|
+
message: error instanceof Error ? error.message : String(error),
|
|
238
|
+
}),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
export class ClaimService extends Effect.Service<ClaimService>()(
|
|
242
|
+
"ClaimService",
|
|
243
|
+
{
|
|
244
|
+
sync: () => ({
|
|
245
|
+
inspect: (
|
|
246
|
+
taskId: string,
|
|
247
|
+
phaseId?: string,
|
|
248
|
+
startPath: string = process.cwd(),
|
|
249
|
+
) =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
const fs = yield* FileSystemService
|
|
252
|
+
const workbase = yield* WorkbaseService
|
|
253
|
+
const tasks = yield* TaskService
|
|
254
|
+
const phases = yield* PhaseService
|
|
255
|
+
const root = yield* workbase.discover(startPath)
|
|
256
|
+
const task = yield* tasks.show(taskId, root)
|
|
257
|
+
const phase = phaseId
|
|
258
|
+
? yield* phases.show(task.id, phaseId, root)
|
|
259
|
+
: undefined
|
|
260
|
+
const target: ClaimTarget = phaseId
|
|
261
|
+
? {
|
|
262
|
+
kind: "phase",
|
|
263
|
+
taskId: task.id,
|
|
264
|
+
phaseId,
|
|
265
|
+
path: phase!.path,
|
|
266
|
+
label: `phase '${task.id}/${phaseId}'`,
|
|
267
|
+
}
|
|
268
|
+
: {
|
|
269
|
+
kind: "task",
|
|
270
|
+
taskId: task.id,
|
|
271
|
+
path: task.path,
|
|
272
|
+
label: `task '${task.id}'`,
|
|
273
|
+
}
|
|
274
|
+
if (!phaseId && "phases" in task.data) {
|
|
275
|
+
return yield* new ClaimError({
|
|
276
|
+
target: target.label,
|
|
277
|
+
message: `Task '${task.id}' has multiple phases; claim a phase instead`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
const content = yield* fs.readFile(target.path)
|
|
281
|
+
const parsed = parseFrontmatterSync(content, target.path)
|
|
282
|
+
return {
|
|
283
|
+
target,
|
|
284
|
+
revision: documentRevision(content),
|
|
285
|
+
data: decodeExecution(target, parsed.data),
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
|
|
289
|
+
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
for (const [label, value] of [
|
|
292
|
+
["Claimant", input.claimant],
|
|
293
|
+
["Runner", input.runner],
|
|
294
|
+
["Session ID", input.sessionId],
|
|
295
|
+
] as const) {
|
|
296
|
+
assertIdentity(label, value)
|
|
297
|
+
}
|
|
298
|
+
const service = yield* ClaimService
|
|
299
|
+
const inspected = yield* service.inspect(
|
|
300
|
+
input.taskId,
|
|
301
|
+
input.phaseId,
|
|
302
|
+
startPath,
|
|
303
|
+
)
|
|
304
|
+
const now = input.now ?? new Date()
|
|
305
|
+
if (
|
|
306
|
+
input.expiresAt !== undefined &&
|
|
307
|
+
(!isIsoTimestamp(input.expiresAt) ||
|
|
308
|
+
Date.parse(input.expiresAt) <= now.getTime())
|
|
309
|
+
) {
|
|
310
|
+
return yield* new ClaimError({
|
|
311
|
+
message: "Claim expiry must be a future ISO-8601 timestamp",
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
return yield* operation(() =>
|
|
315
|
+
updateAtomically(
|
|
316
|
+
inspected.target,
|
|
317
|
+
input.revision,
|
|
318
|
+
(data, operationTime) => {
|
|
319
|
+
const replacingExpiredClaim =
|
|
320
|
+
data.claim?.state === "active" &&
|
|
321
|
+
!isUnexpired(data.claim, operationTime)
|
|
322
|
+
if (data.claim && isUnexpired(data.claim, operationTime)) {
|
|
323
|
+
throw new ClaimConflictError({
|
|
324
|
+
target: inspected.target.label,
|
|
325
|
+
currentRevision: input.revision,
|
|
326
|
+
claim: data.claim,
|
|
327
|
+
message: `${inspected.target.label} is claimed by '${data.claim.runner}'`,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
!data.claim &&
|
|
332
|
+
(data.status === "working" || data.status === "delegated")
|
|
333
|
+
) {
|
|
334
|
+
throw new ClaimConflictError({
|
|
335
|
+
target: inspected.target.label,
|
|
336
|
+
currentRevision: input.revision,
|
|
337
|
+
legacyStatus: data.status,
|
|
338
|
+
message: `${inspected.target.label} has legacy '${data.status}' ownership; reopen it before claiming`,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
if (data.status !== "open" && !replacingExpiredClaim) {
|
|
342
|
+
throw new ClaimError({
|
|
343
|
+
target: inspected.target.label,
|
|
344
|
+
message: `${inspected.target.label} cannot be claimed while ${data.status}`,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
const claim: ClaimRecord = {
|
|
348
|
+
claimant: input.claimant.trim(),
|
|
349
|
+
runner: input.runner.trim(),
|
|
350
|
+
sessionId: input.sessionId.trim(),
|
|
351
|
+
startedAt: operationTime.toISOString(),
|
|
352
|
+
targetRevision: input.revision,
|
|
353
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
354
|
+
state: "active",
|
|
355
|
+
}
|
|
356
|
+
return { data: { ...data, status: "working", claim }, claim }
|
|
357
|
+
},
|
|
358
|
+
now,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
}),
|
|
362
|
+
|
|
363
|
+
release: (input: OwnedClaimInput, startPath: string = process.cwd()) =>
|
|
364
|
+
Effect.gen(function* () {
|
|
365
|
+
assertIdentity("Session ID", input.sessionId)
|
|
366
|
+
const service = yield* ClaimService
|
|
367
|
+
const inspected = yield* service.inspect(
|
|
368
|
+
input.taskId,
|
|
369
|
+
input.phaseId,
|
|
370
|
+
startPath,
|
|
371
|
+
)
|
|
372
|
+
return yield* operation(() =>
|
|
373
|
+
updateAtomically(
|
|
374
|
+
inspected.target,
|
|
375
|
+
input.revision,
|
|
376
|
+
(data, now) => {
|
|
377
|
+
if (
|
|
378
|
+
!data.claim ||
|
|
379
|
+
data.claim.state !== "active" ||
|
|
380
|
+
data.claim.sessionId !== input.sessionId
|
|
381
|
+
) {
|
|
382
|
+
throw new ClaimOwnershipError({
|
|
383
|
+
target: inspected.target.label,
|
|
384
|
+
currentRevision: input.revision,
|
|
385
|
+
sessionId: input.sessionId,
|
|
386
|
+
claim: data.claim,
|
|
387
|
+
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
const claim: ClaimRecord = {
|
|
391
|
+
...data.claim,
|
|
392
|
+
state: "released",
|
|
393
|
+
releasedAt: now.toISOString(),
|
|
394
|
+
}
|
|
395
|
+
return { data: { ...data, status: "open", claim }, claim }
|
|
396
|
+
},
|
|
397
|
+
input.now ?? new Date(),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
}),
|
|
401
|
+
|
|
402
|
+
finish: (input: FinishInput, startPath: string = process.cwd()) =>
|
|
403
|
+
Effect.gen(function* () {
|
|
404
|
+
assertIdentity("Session ID", input.sessionId)
|
|
405
|
+
const service = yield* ClaimService
|
|
406
|
+
const inspected = yield* service.inspect(
|
|
407
|
+
input.taskId,
|
|
408
|
+
input.phaseId,
|
|
409
|
+
startPath,
|
|
410
|
+
)
|
|
411
|
+
return yield* operation(() =>
|
|
412
|
+
updateAtomically(
|
|
413
|
+
inspected.target,
|
|
414
|
+
input.revision,
|
|
415
|
+
(data, now) => {
|
|
416
|
+
if (
|
|
417
|
+
!data.claim ||
|
|
418
|
+
data.claim.state !== "active" ||
|
|
419
|
+
data.claim.sessionId !== input.sessionId
|
|
420
|
+
) {
|
|
421
|
+
throw new ClaimOwnershipError({
|
|
422
|
+
target: inspected.target.label,
|
|
423
|
+
currentRevision: input.revision,
|
|
424
|
+
sessionId: input.sessionId,
|
|
425
|
+
claim: data.claim,
|
|
426
|
+
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
const claim: ClaimRecord = {
|
|
430
|
+
...data.claim,
|
|
431
|
+
state: "finished",
|
|
432
|
+
finishedAt: now.toISOString(),
|
|
433
|
+
outcome: input.outcome,
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
data: { ...data, status: input.outcome, claim },
|
|
437
|
+
claim,
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
input.now ?? new Date(),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
}),
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
) {}
|
|
@@ -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>(
|
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) =>
|