@markjaquith/agency 3.2.2 → 3.2.3
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 +20 -58
- package/cli-main.ts +0 -72
- package/fixtures/protocol/orchestration-recipes.json +9 -34
- package/package.json +1 -4
- package/schemas/agency-graph-v1.schema.json +2 -31
- package/src/cli-parser.test.ts +13 -132
- package/src/cli-parser.ts +10 -101
- package/src/cli.test.ts +12 -111
- package/src/commands/act.ts +2 -6
- package/src/commands/sync.ts +3 -3
- package/src/commands/validate.ts +3 -1
- package/src/commands/work.test.ts +70 -8
- package/src/commands/work.ts +15 -8
- package/src/graph-schema.ts +0 -2
- package/src/protocol.test.ts +0 -25
- package/src/protocol.ts +0 -11
- package/src/readiness.test.ts +2 -2
- package/src/services/ArchiveBulkService.test.ts +0 -88
- package/src/services/ArchiveService.ts +0 -32
- package/src/services/GraphMutationService.ts +0 -26
- package/src/services/GraphService.test.ts +1 -1
- package/src/services/IntegrationService.test.ts +4 -4
- package/src/services/LifecycleTransaction.ts +5 -1
- package/src/services/PhaseService.ts +1 -8
- package/src/services/ReadinessService.test.ts +0 -34
- package/src/services/ReadinessService.ts +1 -20
- package/src/services/ReviewService.test.ts +0 -64
- package/src/services/ReviewService.ts +0 -5
- package/src/services/SyncService.test.ts +75 -88
- package/src/services/SyncService.ts +52 -123
- package/src/services/TaskPhaseService.test.ts +13 -1
- package/src/services/TaskService.ts +1 -7
- package/src/services/WorkbaseService.ts +0 -3
- package/src/services/WorktreeService.test.ts +192 -1
- package/src/services/WorktreeService.ts +169 -34
- package/src/test-utils.ts +0 -2
- package/src/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +0 -3
- package/src/workbase/agent-command.ts +0 -6
- package/src/workbase/document-revision.ts +0 -4
- package/src/workbase/schemas.test.ts +12 -25
- package/src/workbase/schemas.ts +0 -16
- package/src/commands/claim.ts +0 -122
- package/src/services/ClaimService.test.ts +0 -415
- package/src/services/ClaimService.ts +0 -608
package/src/cli-parser.ts
CHANGED
|
@@ -114,21 +114,6 @@ const mutationOptions = {
|
|
|
114
114
|
"no-epic": { type: "boolean" },
|
|
115
115
|
} satisfies OptionConfig
|
|
116
116
|
|
|
117
|
-
const claimOptions = {
|
|
118
|
-
...outputOptions,
|
|
119
|
-
claimant: { type: "string" },
|
|
120
|
-
agent: { type: "string" },
|
|
121
|
-
"session-id": { type: "string" },
|
|
122
|
-
revision: { type: "string" },
|
|
123
|
-
"expires-at": { type: "string" },
|
|
124
|
-
} satisfies OptionConfig
|
|
125
|
-
|
|
126
|
-
const ownedClaimOptions = {
|
|
127
|
-
...outputOptions,
|
|
128
|
-
"session-id": { type: "string" },
|
|
129
|
-
revision: { type: "string" },
|
|
130
|
-
} satisfies OptionConfig
|
|
131
|
-
|
|
132
117
|
const nonPrCompletionOptions = {
|
|
133
118
|
"no-pull-request": { type: "boolean" },
|
|
134
119
|
summary: { type: "string" },
|
|
@@ -679,75 +664,6 @@ const commands = {
|
|
|
679
664
|
},
|
|
680
665
|
},
|
|
681
666
|
},
|
|
682
|
-
claim: {
|
|
683
|
-
usage: "agency claim <task-id> [phase-id] [options]",
|
|
684
|
-
options: {
|
|
685
|
-
...claimOptions,
|
|
686
|
-
task: { type: "string" },
|
|
687
|
-
phase: { type: "string" },
|
|
688
|
-
},
|
|
689
|
-
command: {
|
|
690
|
-
usage:
|
|
691
|
-
"agency claim <task-id> [phase-id] --claimant <id> --agent <id> --session-id <id> --revision <sha256> [--expires-at <timestamp>] [--json]",
|
|
692
|
-
minArgs: 1,
|
|
693
|
-
maxArgs: 2,
|
|
694
|
-
options: [
|
|
695
|
-
"claimant",
|
|
696
|
-
"agent",
|
|
697
|
-
"session-id",
|
|
698
|
-
"revision",
|
|
699
|
-
"expires-at",
|
|
700
|
-
"json",
|
|
701
|
-
"task",
|
|
702
|
-
"phase",
|
|
703
|
-
],
|
|
704
|
-
required: ["claimant", "agent", "session-id", "revision"],
|
|
705
|
-
},
|
|
706
|
-
},
|
|
707
|
-
release: {
|
|
708
|
-
usage: "agency release <task-id> [phase-id] [options]",
|
|
709
|
-
options: {
|
|
710
|
-
...ownedClaimOptions,
|
|
711
|
-
task: { type: "string" },
|
|
712
|
-
phase: { type: "string" },
|
|
713
|
-
},
|
|
714
|
-
command: {
|
|
715
|
-
usage:
|
|
716
|
-
"agency release <task-id> [phase-id] --session-id <id> --revision <sha256> [--json]",
|
|
717
|
-
minArgs: 1,
|
|
718
|
-
maxArgs: 2,
|
|
719
|
-
options: ["session-id", "revision", "json", "task", "phase"],
|
|
720
|
-
required: ["session-id", "revision"],
|
|
721
|
-
},
|
|
722
|
-
},
|
|
723
|
-
finish: {
|
|
724
|
-
usage: "agency finish <task-id> [phase-id] [options]",
|
|
725
|
-
options: {
|
|
726
|
-
...ownedClaimOptions,
|
|
727
|
-
...nonPrCompletionOptions,
|
|
728
|
-
outcome: { type: "string" },
|
|
729
|
-
task: { type: "string" },
|
|
730
|
-
phase: { type: "string" },
|
|
731
|
-
},
|
|
732
|
-
command: {
|
|
733
|
-
usage:
|
|
734
|
-
"agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [options] [--json]",
|
|
735
|
-
minArgs: 1,
|
|
736
|
-
maxArgs: 2,
|
|
737
|
-
options: [
|
|
738
|
-
"session-id",
|
|
739
|
-
"revision",
|
|
740
|
-
"outcome",
|
|
741
|
-
"no-pull-request",
|
|
742
|
-
"summary",
|
|
743
|
-
"evidence-url",
|
|
744
|
-
"json",
|
|
745
|
-
"task",
|
|
746
|
-
"phase",
|
|
747
|
-
],
|
|
748
|
-
required: ["session-id", "revision", "outcome"],
|
|
749
|
-
},
|
|
750
|
-
},
|
|
751
667
|
sync: {
|
|
752
668
|
usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
|
|
753
669
|
options: {
|
|
@@ -1177,8 +1093,6 @@ const targetSlots = (
|
|
|
1177
1093
|
return ["task"]
|
|
1178
1094
|
if (commandName === "phase")
|
|
1179
1095
|
return subcommand === "list" ? ["task"] : ["task", "phase"]
|
|
1180
|
-
if (["claim", "release", "finish"].includes(commandName))
|
|
1181
|
-
return ["task", "phase"]
|
|
1182
1096
|
if (["archive", "restore"].includes(commandName))
|
|
1183
1097
|
return subcommand === "epic"
|
|
1184
1098
|
? ["epic"]
|
|
@@ -1637,16 +1551,6 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1637
1551
|
) {
|
|
1638
1552
|
validateViewOptions(parsed.values, spec)
|
|
1639
1553
|
}
|
|
1640
|
-
if (
|
|
1641
|
-
commandName === "finish" &&
|
|
1642
|
-
parsed.values.outcome !== "done" &&
|
|
1643
|
-
parsed.values.outcome !== "dropped"
|
|
1644
|
-
) {
|
|
1645
|
-
throw usageError(
|
|
1646
|
-
"Option '--outcome' must be 'done' or 'dropped'.",
|
|
1647
|
-
spec.usage,
|
|
1648
|
-
)
|
|
1649
|
-
}
|
|
1650
1554
|
const nonPrCompletion = parsed.values["no-pull-request"] === true
|
|
1651
1555
|
const completionSummary = parsed.values.summary
|
|
1652
1556
|
const completionEvidenceUrl = parsed.values["evidence-url"]
|
|
@@ -1670,19 +1574,24 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1670
1574
|
)
|
|
1671
1575
|
}
|
|
1672
1576
|
const completesDone =
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
commandPositionals.at(-1) === "done")
|
|
1577
|
+
["task", "phase"].includes(commandName) &&
|
|
1578
|
+
subcommand === "status" &&
|
|
1579
|
+
commandPositionals.at(-1) === "done"
|
|
1677
1580
|
if (!completesDone) {
|
|
1678
1581
|
throw usageError(
|
|
1679
|
-
"Option '--no-pull-request' is valid only with a done status
|
|
1582
|
+
"Option '--no-pull-request' is valid only with a done status.",
|
|
1680
1583
|
spec.usage,
|
|
1681
1584
|
)
|
|
1682
1585
|
}
|
|
1683
1586
|
}
|
|
1684
1587
|
if (commandName === "work") {
|
|
1685
1588
|
const preparing = commandPositionals[0] === "prepare"
|
|
1589
|
+
if (preparing && parsed.values.phase && !parsed.values.task) {
|
|
1590
|
+
throw usageError(
|
|
1591
|
+
"Option '--phase' requires '--task' with work preparation.",
|
|
1592
|
+
spec.usage,
|
|
1593
|
+
)
|
|
1594
|
+
}
|
|
1686
1595
|
if (
|
|
1687
1596
|
(!preparing && commandPositionals.length > 1) ||
|
|
1688
1597
|
(preparing &&
|
package/src/cli.test.ts
CHANGED
|
@@ -210,131 +210,23 @@ describe("CLI", () => {
|
|
|
210
210
|
expect(exported.stdout).not.toContain("/private/value")
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
-
test("
|
|
213
|
+
test("records status-based non-PR completion", async () => {
|
|
214
214
|
const root = await createTempDir()
|
|
215
215
|
tempDirs.push(root)
|
|
216
216
|
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
217
217
|
await mkdir(join(root, "repos", "agency"), { recursive: true })
|
|
218
|
-
parseJson(
|
|
219
|
-
await runCli(
|
|
220
|
-
["task", "create", "claimed", "--repo", "agency", "--json"],
|
|
221
|
-
root,
|
|
222
|
-
),
|
|
223
|
-
)
|
|
224
|
-
const context = parseJson(
|
|
225
|
-
await runCli(["context", "tasks/claimed", "--json"], root),
|
|
226
|
-
)
|
|
227
|
-
const revision = context.documents.task.sha256
|
|
228
|
-
const claimed = parseJson(
|
|
229
|
-
await runCli(
|
|
230
|
-
[
|
|
231
|
-
"claim",
|
|
232
|
-
"claimed",
|
|
233
|
-
"--claimant",
|
|
234
|
-
"orchestrator",
|
|
235
|
-
"--agent",
|
|
236
|
-
"agent",
|
|
237
|
-
"--session-id",
|
|
238
|
-
"job-1",
|
|
239
|
-
"--revision",
|
|
240
|
-
revision,
|
|
241
|
-
"--json",
|
|
242
|
-
],
|
|
243
|
-
root,
|
|
244
|
-
),
|
|
245
|
-
)
|
|
246
|
-
expect(claimed.claim).toMatchObject({
|
|
247
|
-
claimant: "orchestrator",
|
|
248
|
-
agent: "agent",
|
|
249
|
-
sessionId: "job-1",
|
|
250
|
-
state: "active",
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
const conflict = await runCli(
|
|
254
|
-
[
|
|
255
|
-
"claim",
|
|
256
|
-
"claimed",
|
|
257
|
-
"--claimant",
|
|
258
|
-
"other",
|
|
259
|
-
"--agent",
|
|
260
|
-
"other-agent",
|
|
261
|
-
"--session-id",
|
|
262
|
-
"job-2",
|
|
263
|
-
"--revision",
|
|
264
|
-
claimed.revision,
|
|
265
|
-
"--json",
|
|
266
|
-
],
|
|
267
|
-
root,
|
|
268
|
-
)
|
|
269
|
-
expect(conflict.exitCode).toBe(1)
|
|
270
|
-
expect(JSON.parse(conflict.stdout)).toMatchObject({
|
|
271
|
-
ok: false,
|
|
272
|
-
error: {
|
|
273
|
-
code: "CLAIM_CONFLICT",
|
|
274
|
-
retryable: true,
|
|
275
|
-
fields: { claim: { agent: "agent", sessionId: "job-1" } },
|
|
276
|
-
},
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
const finished = parseJson(
|
|
280
|
-
await runCli(
|
|
281
|
-
[
|
|
282
|
-
"finish",
|
|
283
|
-
"claimed",
|
|
284
|
-
"--session-id",
|
|
285
|
-
"job-1",
|
|
286
|
-
"--revision",
|
|
287
|
-
claimed.revision,
|
|
288
|
-
"--outcome",
|
|
289
|
-
"done",
|
|
290
|
-
"--json",
|
|
291
|
-
],
|
|
292
|
-
root,
|
|
293
|
-
),
|
|
294
|
-
)
|
|
295
|
-
expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
|
|
296
|
-
expect(
|
|
297
|
-
parseJson(await runCli(["task", "show", "claimed", "--json"], root)).data
|
|
298
|
-
.status,
|
|
299
|
-
).toBe("working")
|
|
300
|
-
|
|
301
218
|
parseJson(
|
|
302
219
|
await runCli(
|
|
303
220
|
["task", "create", "non-pr", "--repo", "agency", "--json"],
|
|
304
221
|
root,
|
|
305
222
|
),
|
|
306
223
|
)
|
|
307
|
-
const nonPrContext = parseJson(
|
|
308
|
-
await runCli(["context", "tasks/non-pr", "--json"], root),
|
|
309
|
-
)
|
|
310
|
-
const nonPrClaim = parseJson(
|
|
311
|
-
await runCli(
|
|
312
|
-
[
|
|
313
|
-
"claim",
|
|
314
|
-
"non-pr",
|
|
315
|
-
"--claimant",
|
|
316
|
-
"orchestrator",
|
|
317
|
-
"--agent",
|
|
318
|
-
"agent",
|
|
319
|
-
"--session-id",
|
|
320
|
-
"job-2",
|
|
321
|
-
"--revision",
|
|
322
|
-
nonPrContext.documents.task.sha256,
|
|
323
|
-
"--json",
|
|
324
|
-
],
|
|
325
|
-
root,
|
|
326
|
-
),
|
|
327
|
-
)
|
|
328
224
|
parseJson(
|
|
329
225
|
await runCli(
|
|
330
226
|
[
|
|
331
|
-
"
|
|
227
|
+
"task",
|
|
228
|
+
"status",
|
|
332
229
|
"non-pr",
|
|
333
|
-
"--session-id",
|
|
334
|
-
"job-2",
|
|
335
|
-
"--revision",
|
|
336
|
-
nonPrClaim.revision,
|
|
337
|
-
"--outcome",
|
|
338
230
|
"done",
|
|
339
231
|
"--no-pull-request",
|
|
340
232
|
"--summary",
|
|
@@ -1073,6 +965,15 @@ status: open
|
|
|
1073
965
|
],
|
|
1074
966
|
}),
|
|
1075
967
|
])
|
|
968
|
+
const preparedAgain = parseJson(
|
|
969
|
+
await runCli(
|
|
970
|
+
["work", "prepare", join(root, "tasks/example/TASK.md"), "--json"],
|
|
971
|
+
parent,
|
|
972
|
+
),
|
|
973
|
+
)
|
|
974
|
+
expect(preparedAgain.checkouts).toEqual([
|
|
975
|
+
expect.objectContaining({ action: "reused", kind: "writable" }),
|
|
976
|
+
])
|
|
1076
977
|
const rebuild = parseJson(
|
|
1077
978
|
await runCli(
|
|
1078
979
|
["worktree", "rebuild", "example", "--dry-run", "--json"],
|
package/src/commands/act.ts
CHANGED
|
@@ -106,9 +106,6 @@ const entityChoices = (
|
|
|
106
106
|
value: entityKey(node),
|
|
107
107
|
}))
|
|
108
108
|
|
|
109
|
-
const activeClaim = (node: EntityNode) =>
|
|
110
|
-
"claim" in node.data && node.data.claim?.state === "active"
|
|
111
|
-
|
|
112
109
|
const executionNode = (
|
|
113
110
|
node: EntityNode,
|
|
114
111
|
executions: ReadonlyMap<
|
|
@@ -131,7 +128,6 @@ const canWork = (
|
|
|
131
128
|
const execution = executionNode(node, executions)
|
|
132
129
|
return Boolean(
|
|
133
130
|
execution &&
|
|
134
|
-
!activeClaim(node) &&
|
|
135
131
|
(execution.readiness.ready ||
|
|
136
132
|
(execution.status === "working" &&
|
|
137
133
|
execution.readiness.blockers.every(
|
|
@@ -168,10 +164,10 @@ const actionChoices = (
|
|
|
168
164
|
if (canCreatePr(node, executions)) {
|
|
169
165
|
choices.push({ key: "pr", label: "Create pull request", value: "pr" })
|
|
170
166
|
}
|
|
171
|
-
if (execution && isTerminalStatus(node.status)
|
|
167
|
+
if (execution && isTerminalStatus(node.status)) {
|
|
172
168
|
choices.push({ key: "reopen", label: "Reopen", value: "reopen" })
|
|
173
169
|
}
|
|
174
|
-
if (execution && !isTerminalStatus(node.status)
|
|
170
|
+
if (execution && !isTerminalStatus(node.status)) {
|
|
175
171
|
choices.push({ key: "drop", label: "Drop", value: "drop" })
|
|
176
172
|
}
|
|
177
173
|
if (node.readiness.terminal) {
|
package/src/commands/sync.ts
CHANGED
|
@@ -125,7 +125,7 @@ export const help = `
|
|
|
125
125
|
Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
|
|
126
126
|
|
|
127
127
|
Compare portable repository declarations and execution state with local Git
|
|
128
|
-
repositories, worktrees, branches, references,
|
|
128
|
+
repositories, worktrees, branches, references, and pull requests.
|
|
129
129
|
Safe reconciliation transitions are applied by default.
|
|
130
130
|
When a task or phase is provided, only that target and its repositories are
|
|
131
131
|
queried or reconciled. Task scope includes all phases of a multi-phase task.
|
|
@@ -135,7 +135,7 @@ Options:
|
|
|
135
135
|
--json Output one versioned machine result
|
|
136
136
|
|
|
137
137
|
Sync may materialize declared repositories and unambiguous missing checkouts,
|
|
138
|
-
adopt legacy repositories with portable origins,
|
|
139
|
-
|
|
138
|
+
adopt legacy repositories with portable origins, record a uniquely matched PR,
|
|
139
|
+
and mark merged work done. Dirty, stale, or
|
|
140
140
|
conflicting checkouts are always left unresolved.
|
|
141
141
|
`
|
package/src/commands/validate.ts
CHANGED
|
@@ -13,7 +13,9 @@ interface ValidateOptions extends BaseCommandOptions {
|
|
|
13
13
|
readonly json?: boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
class ValidationFailedError extends Data.TaggedError(
|
|
16
|
+
export class ValidationFailedError extends Data.TaggedError(
|
|
17
|
+
"ValidationFailedError",
|
|
18
|
+
)<{
|
|
17
19
|
readonly message: string
|
|
18
20
|
readonly root: string
|
|
19
21
|
readonly issues: readonly {
|
|
@@ -84,6 +84,8 @@ interface HarnessOptions {
|
|
|
84
84
|
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
85
85
|
>
|
|
86
86
|
readonly taskRepo?: string
|
|
87
|
+
readonly existingPaths?: readonly string[]
|
|
88
|
+
readonly validationIssues?: readonly { path: string; message: string }[]
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -142,15 +144,17 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
142
144
|
},
|
|
143
145
|
}),
|
|
144
146
|
repositoryAliases: () => Effect.succeed(["agency"]),
|
|
145
|
-
validate: () =>
|
|
146
|
-
|
|
147
|
+
validate: () => {
|
|
148
|
+
const issues = options.validationIssues ?? []
|
|
149
|
+
return Effect.succeed({
|
|
147
150
|
root: "/workbase",
|
|
148
|
-
issues
|
|
151
|
+
issues,
|
|
149
152
|
epicCount: 0,
|
|
150
153
|
taskCount: 1,
|
|
151
154
|
phaseCount: 0,
|
|
152
|
-
valid:
|
|
153
|
-
})
|
|
155
|
+
valid: issues.length === 0,
|
|
156
|
+
})
|
|
157
|
+
},
|
|
154
158
|
}
|
|
155
159
|
const epics = {
|
|
156
160
|
show: (id: string) =>
|
|
@@ -281,7 +285,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
281
285
|
readFile: (path: string) =>
|
|
282
286
|
Effect.succeed(path.endsWith("agency.json") ? '{"version":2}\n' : ""),
|
|
283
287
|
readDirectory: () => Effect.succeed([]),
|
|
284
|
-
exists: () =>
|
|
288
|
+
exists: (path: string) =>
|
|
289
|
+
Effect.succeed(options.existingPaths?.includes(path) ?? false),
|
|
285
290
|
realPath: (path: string) => Effect.succeed(path),
|
|
286
291
|
runCommand: (args: readonly string[]) => {
|
|
287
292
|
const cli = args[1]!
|
|
@@ -569,6 +574,65 @@ describe("work command", () => {
|
|
|
569
574
|
).rejects.toThrow("Recalled repository conflicts")
|
|
570
575
|
})
|
|
571
576
|
|
|
577
|
+
test("resolves task and phase document paths", async () => {
|
|
578
|
+
const taskDocument = "/workbase/tasks/example/TASK.md"
|
|
579
|
+
const taskHarness = createHarness({
|
|
580
|
+
existingDirectories: [],
|
|
581
|
+
existingPaths: [taskDocument],
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
await taskHarness.runPrepare({
|
|
585
|
+
directory: taskDocument,
|
|
586
|
+
dryRun: true,
|
|
587
|
+
silent: true,
|
|
588
|
+
})
|
|
589
|
+
expect(taskHarness.guards[0]?.target).toBe("execution-unit:task/example")
|
|
590
|
+
expect(taskHarness.shownTasks).toEqual(["example", "example"])
|
|
591
|
+
|
|
592
|
+
const phaseDocument =
|
|
593
|
+
"/workbase/tasks/example/phases/implementation/PHASE.md"
|
|
594
|
+
const phaseHarness = createHarness({
|
|
595
|
+
existingDirectories: [],
|
|
596
|
+
existingPaths: [phaseDocument],
|
|
597
|
+
multiPhaseTasks: ["example"],
|
|
598
|
+
workspace: multiPhaseWorkspace,
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
await phaseHarness.runPrepare({
|
|
602
|
+
directory: phaseDocument,
|
|
603
|
+
dryRun: true,
|
|
604
|
+
silent: true,
|
|
605
|
+
})
|
|
606
|
+
expect(phaseHarness.guards[0]?.target).toBe(
|
|
607
|
+
"execution-unit:phase/example/implementation",
|
|
608
|
+
)
|
|
609
|
+
expect(phaseHarness.materializeOptions).toHaveLength(1)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test("returns structured validation issues before readiness or materialization", async () => {
|
|
613
|
+
const harness = createHarness({
|
|
614
|
+
existingDirectories: [],
|
|
615
|
+
validationIssues: [
|
|
616
|
+
{
|
|
617
|
+
path: "tasks/example/TASK.md",
|
|
618
|
+
message: "Repository alias 'missing' is not configured",
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
await expect(
|
|
624
|
+
harness.runPrepare({
|
|
625
|
+
cwd: "/workbase",
|
|
626
|
+
directory: "example",
|
|
627
|
+
dryRun: true,
|
|
628
|
+
}),
|
|
629
|
+
).rejects.toThrow(
|
|
630
|
+
"Workbase validation failed with 1 issue:\n- tasks/example/TASK.md: Repository alias 'missing' is not configured",
|
|
631
|
+
)
|
|
632
|
+
expect(harness.guards).toEqual([])
|
|
633
|
+
expect(harness.events).toEqual([])
|
|
634
|
+
})
|
|
635
|
+
|
|
572
636
|
test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
|
|
573
637
|
const blocked = createHarness({
|
|
574
638
|
workspace: multiPhaseWorkspace,
|
|
@@ -1031,12 +1095,10 @@ describe("work command", () => {
|
|
|
1031
1095
|
})
|
|
1032
1096
|
expect(harness.launchEnvironments[0]).toMatchObject({
|
|
1033
1097
|
AGENCY_AGENT: "custom",
|
|
1034
|
-
AGENCY_CLAIMANT: process.env.USER ?? "agency",
|
|
1035
1098
|
AGENCY_WORKBASE: "/workbase",
|
|
1036
1099
|
AGENCY_TARGET: "execution-unit:task/example",
|
|
1037
1100
|
AGENCY_TASK_ID: "example",
|
|
1038
1101
|
AGENCY_PHASE_ID: "",
|
|
1039
|
-
AGENCY_CLAIM_REVISION: "",
|
|
1040
1102
|
CUSTOM_TARGET: "execution-unit:task/example",
|
|
1041
1103
|
})
|
|
1042
1104
|
})
|
package/src/commands/work.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
normalizeRecalledContext,
|
|
37
37
|
readValidationEvidence,
|
|
38
38
|
} from "../workbase/execution-contract"
|
|
39
|
+
import { ValidationFailedError } from "./validate"
|
|
39
40
|
|
|
40
41
|
export interface WorkOptions extends BaseCommandOptions {
|
|
41
42
|
readonly directory?: string
|
|
@@ -288,7 +289,6 @@ export const work = (
|
|
|
288
289
|
const defaultAgents = ["opencode2", "opencode", "pi", "claude"] as const
|
|
289
290
|
let defaultAgentIndex = 0
|
|
290
291
|
let agent: string = selectedAgent ?? defaultAgents[defaultAgentIndex]!
|
|
291
|
-
const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
|
|
292
292
|
const sessionId =
|
|
293
293
|
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
294
294
|
const resume =
|
|
@@ -300,9 +300,7 @@ export const work = (
|
|
|
300
300
|
target: targetNodeId(target),
|
|
301
301
|
task: target.kind === "epic" ? "" : target.taskId,
|
|
302
302
|
phase: target.kind === "phase" ? target.phaseId : "",
|
|
303
|
-
claimant,
|
|
304
303
|
sessionId,
|
|
305
|
-
claimRevision: "",
|
|
306
304
|
}
|
|
307
305
|
let resolved = resolveAgentCommand(
|
|
308
306
|
agent,
|
|
@@ -439,7 +437,8 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
439
437
|
const cwd = options.cwd ?? process.cwd()
|
|
440
438
|
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
441
439
|
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
442
|
-
const
|
|
440
|
+
const targetExists = isDirectory || (yield* fs.exists(targetPath))
|
|
441
|
+
const root = yield* workbase.discover(targetExists ? targetPath : cwd)
|
|
443
442
|
|
|
444
443
|
let taskId = options.taskId
|
|
445
444
|
let phaseId = options.phaseId
|
|
@@ -447,7 +446,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
447
446
|
const task = yield* tasks.show(taskId, root)
|
|
448
447
|
taskId = task.id
|
|
449
448
|
if (phaseId) phaseId = (yield* phases.show(task.id, phaseId, root)).id
|
|
450
|
-
} else if (options.directory && !
|
|
449
|
+
} else if (options.directory && !targetExists) {
|
|
451
450
|
const task = yield* tasks.show(options.directory, root)
|
|
452
451
|
taskId = task.id
|
|
453
452
|
} else {
|
|
@@ -526,9 +525,17 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
526
525
|
})
|
|
527
526
|
let validation: unknown = { valid: true, source: "evidence" }
|
|
528
527
|
if (assessment.disposition.status === "refreshed") {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
528
|
+
const report = yield* workbase.validate(root)
|
|
529
|
+
validation = report
|
|
530
|
+
if (!report.valid && !options.force) {
|
|
531
|
+
const details = report.issues
|
|
532
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
533
|
+
.join("\n")
|
|
534
|
+
return yield* new ValidationFailedError({
|
|
535
|
+
message: `Workbase validation failed with ${report.issues.length} issue${report.issues.length === 1 ? "" : "s"}:\n${details}`,
|
|
536
|
+
root: report.root,
|
|
537
|
+
issues: report.issues,
|
|
538
|
+
})
|
|
532
539
|
}
|
|
533
540
|
}
|
|
534
541
|
yield* readiness.guardWorkTarget(target, root, options.force)
|
package/src/graph-schema.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
EpicFrontmatter,
|
|
4
4
|
PhaseFrontmatter,
|
|
5
5
|
PullRequestRecord,
|
|
6
|
-
ClaimRecord,
|
|
7
6
|
ReviewRecord,
|
|
8
7
|
TaskHandoff,
|
|
9
8
|
TaskFrontmatter,
|
|
@@ -96,7 +95,6 @@ export const GraphExecutionData = Schema.Union(
|
|
|
96
95
|
handoff: Schema.optional(TaskHandoff),
|
|
97
96
|
review: ReviewRecord,
|
|
98
97
|
status: WorkStatus,
|
|
99
|
-
claim: Schema.optional(ClaimRecord),
|
|
100
98
|
}),
|
|
101
99
|
)
|
|
102
100
|
|
package/src/protocol.test.ts
CHANGED
|
@@ -116,31 +116,6 @@ describe("machine protocol", () => {
|
|
|
116
116
|
"issues",
|
|
117
117
|
"root",
|
|
118
118
|
])
|
|
119
|
-
expect(
|
|
120
|
-
errorEnvelope({
|
|
121
|
-
_tag: "ClaimConflictError",
|
|
122
|
-
message: "already claimed",
|
|
123
|
-
target: "task 'example'",
|
|
124
|
-
currentRevision: "a".repeat(64),
|
|
125
|
-
claim: {
|
|
126
|
-
claimant: "orchestrator",
|
|
127
|
-
agent: "agent",
|
|
128
|
-
sessionId: "job-1",
|
|
129
|
-
startedAt: "2026-07-17T12:00:00.000Z",
|
|
130
|
-
targetRevision: "0".repeat(64),
|
|
131
|
-
state: "active",
|
|
132
|
-
},
|
|
133
|
-
}),
|
|
134
|
-
).toMatchObject({
|
|
135
|
-
error: {
|
|
136
|
-
code: "CLAIM_CONFLICT",
|
|
137
|
-
retryable: true,
|
|
138
|
-
fields: {
|
|
139
|
-
target: "task 'example'",
|
|
140
|
-
claim: { agent: "agent", sessionId: "job-1" },
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
})
|
|
144
119
|
expect(
|
|
145
120
|
errorEnvelope({
|
|
146
121
|
_tag: "RevisionConflictError",
|
package/src/protocol.ts
CHANGED
|
@@ -81,22 +81,11 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
81
81
|
EpicError: { code: "EPIC_ERROR", retryable: false },
|
|
82
82
|
TaskError: { code: "TASK_ERROR", retryable: false },
|
|
83
83
|
PhaseError: { code: "PHASE_ERROR", retryable: false },
|
|
84
|
-
ClaimError: { code: "CLAIM_ERROR", retryable: false },
|
|
85
|
-
ClaimConflictError: {
|
|
86
|
-
code: "CLAIM_CONFLICT",
|
|
87
|
-
retryable: true,
|
|
88
|
-
remediation: "Inspect the current ownership details before retrying.",
|
|
89
|
-
},
|
|
90
84
|
RevisionConflictError: {
|
|
91
85
|
code: "REVISION_CONFLICT",
|
|
92
86
|
retryable: true,
|
|
93
87
|
remediation: "Read the current document revision and retry intentionally.",
|
|
94
88
|
},
|
|
95
|
-
ClaimOwnershipError: {
|
|
96
|
-
code: "CLAIM_OWNERSHIP",
|
|
97
|
-
retryable: false,
|
|
98
|
-
remediation: "Use the session that owns the claim.",
|
|
99
|
-
},
|
|
100
89
|
ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
|
|
101
90
|
WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
|
|
102
91
|
PushError: { code: "PUSH_ERROR", retryable: false },
|
package/src/readiness.test.ts
CHANGED
|
@@ -27,10 +27,10 @@ describe("readiness model", () => {
|
|
|
27
27
|
blockedBy: ["task:one"],
|
|
28
28
|
terminal: true,
|
|
29
29
|
})
|
|
30
|
-
expect(readinessState("working", [{ id: "
|
|
30
|
+
expect(readinessState("working", [{ id: "dependency:self" }])).toEqual({
|
|
31
31
|
ready: false,
|
|
32
32
|
blocked: true,
|
|
33
|
-
blockedBy: ["
|
|
33
|
+
blockedBy: ["dependency:self"],
|
|
34
34
|
terminal: false,
|
|
35
35
|
})
|
|
36
36
|
})
|