@markjaquith/agency 2.59.0 → 2.61.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 +96 -4
- package/cli-main.ts +7 -0
- package/fixtures/protocol/orchestration-recipes.json +19 -0
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-graph-v1.schema.json +53 -0
- package/schemas/agency-kickoff-v1.schema.json +124 -0
- package/src/cli-parser.test.ts +50 -2
- package/src/cli-parser.ts +52 -6
- package/src/commands/task-phase.test.ts +49 -0
- package/src/commands/task.test.ts +77 -1
- package/src/commands/task.ts +123 -7
- package/src/commands/work.test.ts +62 -3
- package/src/commands/work.ts +106 -3
- package/src/graph-schema.test.ts +8 -0
- package/src/graph-schema.ts +7 -0
- package/src/services/ArchiveService.test.ts +18 -1
- package/src/services/GraphMutationService.test.ts +48 -0
- package/src/services/IntegrationService.test.ts +47 -0
- package/src/services/LifecycleTransaction.ts +12 -0
- package/src/services/PhaseService.ts +2 -0
- package/src/services/TaskPhaseService.test.ts +238 -0
- package/src/services/TaskService.ts +265 -6
- package/src/services/WorkbaseService.ts +30 -0
- package/src/services/WorktreeService.ts +9 -6
- package/src/workbase/AGENTS.md +80 -4
- package/src/workbase/frontmatter.test.ts +18 -1
- package/src/workbase/frontmatter.ts +29 -1
- package/src/workbase/kickoff-contract.test.ts +145 -0
- package/src/workbase/kickoff-contract.ts +358 -0
- package/src/workbase/opencode-file.ts +1 -1
- package/src/workbase/schemas.test.ts +36 -0
- package/src/workbase/schemas.ts +33 -1
|
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { mkdir } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
captureLogs,
|
|
7
|
+
cleanupTempDir,
|
|
8
|
+
createTempDir,
|
|
9
|
+
runTestEffect,
|
|
10
|
+
} from "../test-utils"
|
|
6
11
|
import { task, type TaskInteraction } from "./task"
|
|
7
12
|
|
|
8
13
|
describe("task creation input", () => {
|
|
@@ -125,6 +130,77 @@ describe("task creation input", () => {
|
|
|
125
130
|
expect(content).toContain("base: main")
|
|
126
131
|
})
|
|
127
132
|
|
|
133
|
+
test("returns revision-bound validation evidence and normalized recalled context", async () => {
|
|
134
|
+
const [output] = await captureLogs(() =>
|
|
135
|
+
runTestEffect(
|
|
136
|
+
task({
|
|
137
|
+
subcommand: "create",
|
|
138
|
+
args: ["scripted-context"],
|
|
139
|
+
contextRepo: "agency",
|
|
140
|
+
contextBase: "main",
|
|
141
|
+
contextSlug: "scripted-context",
|
|
142
|
+
authoritativeSources: [
|
|
143
|
+
"https://example.com/spec",
|
|
144
|
+
join(root, "SOURCE.md"),
|
|
145
|
+
],
|
|
146
|
+
cwd: root,
|
|
147
|
+
json: true,
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
const result = JSON.parse(output!)
|
|
152
|
+
expect(result.selector).toBe("execution-unit:task/scripted-context")
|
|
153
|
+
expect(result.documentPath).toBe(
|
|
154
|
+
join(root, "tasks/scripted-context/TASK.md"),
|
|
155
|
+
)
|
|
156
|
+
expect(result.validation.valid).toBe(true)
|
|
157
|
+
expect(result.evidence).toEqual(
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
version: 1,
|
|
160
|
+
target: "execution-unit:task/scripted-context",
|
|
161
|
+
documentRevision: result.revision,
|
|
162
|
+
valid: true,
|
|
163
|
+
digest: expect.any(String),
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
expect(result.recalledContext).toEqual({
|
|
167
|
+
repo: "agency",
|
|
168
|
+
base: "main",
|
|
169
|
+
preferredSlug: "scripted-context",
|
|
170
|
+
authoritativeSources: [
|
|
171
|
+
join(root, "SOURCE.md"),
|
|
172
|
+
"https://example.com/spec",
|
|
173
|
+
],
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("rejects stale or conflicting recalled context", async () => {
|
|
178
|
+
await expect(
|
|
179
|
+
runTestEffect(
|
|
180
|
+
task({
|
|
181
|
+
subcommand: "create",
|
|
182
|
+
args: ["conflict"],
|
|
183
|
+
repo: "agency",
|
|
184
|
+
contextRepo: "other",
|
|
185
|
+
cwd: root,
|
|
186
|
+
silent: true,
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
).rejects.toThrow("conflicts with --repo")
|
|
190
|
+
await expect(
|
|
191
|
+
runTestEffect(
|
|
192
|
+
task({
|
|
193
|
+
subcommand: "create",
|
|
194
|
+
args: ["conflict"],
|
|
195
|
+
contextRepo: "agency",
|
|
196
|
+
contextSlug: "stale-slug",
|
|
197
|
+
cwd: root,
|
|
198
|
+
silent: true,
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
).rejects.toThrow("conflicts with task ID")
|
|
202
|
+
})
|
|
203
|
+
|
|
128
204
|
test("never prompts when scripted creation is incomplete", async () => {
|
|
129
205
|
const interaction: TaskInteraction = {
|
|
130
206
|
text: () => Effect.fail(new Error("unexpected text prompt")),
|
package/src/commands/task.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { getWorkViews } from "../work-view"
|
|
|
12
12
|
import { GraphMutationService } from "../services/GraphMutationService"
|
|
13
13
|
import { work as startWork, type StartWork } from "./work"
|
|
14
14
|
import { ReviewService } from "../services/ReviewService"
|
|
15
|
+
import {
|
|
16
|
+
buildValidationEvidence,
|
|
17
|
+
normalizeRecalledContext,
|
|
18
|
+
} from "../workbase/kickoff-contract"
|
|
15
19
|
|
|
16
20
|
interface TaskOptions extends BaseCommandOptions {
|
|
17
21
|
readonly subcommand?: string
|
|
@@ -45,6 +49,12 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
45
49
|
readonly noPullRequest?: boolean
|
|
46
50
|
readonly summary?: string
|
|
47
51
|
readonly evidenceUrl?: string
|
|
52
|
+
readonly contextRepo?: string
|
|
53
|
+
readonly contextBase?: string
|
|
54
|
+
readonly contextSlug?: string
|
|
55
|
+
readonly authoritativeSources?: readonly string[]
|
|
56
|
+
readonly purpose?: string
|
|
57
|
+
readonly sourcePhase?: string
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface TaskInteraction {
|
|
@@ -189,6 +199,7 @@ export const task = (
|
|
|
189
199
|
repos: parseRepositoryReferences(options.references),
|
|
190
200
|
branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
|
|
191
201
|
base: multiPhase ? undefined : (options.base ?? "main"),
|
|
202
|
+
purpose: options.purpose as "investigation" | undefined,
|
|
192
203
|
},
|
|
193
204
|
cwd,
|
|
194
205
|
)
|
|
@@ -216,7 +227,43 @@ export const task = (
|
|
|
216
227
|
return yield* Effect.fail(new Error("Task ID is required"))
|
|
217
228
|
}
|
|
218
229
|
const multiPhase = options.multiPhase ?? false
|
|
219
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
options.repo &&
|
|
232
|
+
options.contextRepo &&
|
|
233
|
+
options.repo !== options.contextRepo
|
|
234
|
+
) {
|
|
235
|
+
return yield* Effect.fail(
|
|
236
|
+
new Error(
|
|
237
|
+
`Recalled repository '${options.contextRepo}' conflicts with --repo '${options.repo}'`,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
options.base &&
|
|
243
|
+
options.contextBase &&
|
|
244
|
+
options.base !== options.contextBase
|
|
245
|
+
) {
|
|
246
|
+
return yield* Effect.fail(
|
|
247
|
+
new Error(
|
|
248
|
+
`Recalled base '${options.contextBase}' conflicts with --base '${options.base}'`,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
const repo = options.repo ?? options.contextRepo
|
|
253
|
+
const base = options.base ?? options.contextBase ?? "main"
|
|
254
|
+
const recalledContext = yield* Effect.try({
|
|
255
|
+
try: () =>
|
|
256
|
+
normalizeRecalledContext({
|
|
257
|
+
id,
|
|
258
|
+
repo,
|
|
259
|
+
base: multiPhase || options.review ? undefined : base,
|
|
260
|
+
preferredSlug: options.contextSlug,
|
|
261
|
+
authoritativeSources: options.authoritativeSources,
|
|
262
|
+
}),
|
|
263
|
+
catch: (cause) =>
|
|
264
|
+
cause instanceof Error ? cause : new Error(String(cause)),
|
|
265
|
+
})
|
|
266
|
+
if (!multiPhase && !repo && !options.review) {
|
|
220
267
|
return yield* Effect.fail(
|
|
221
268
|
new Error("Writable repository is required for task create"),
|
|
222
269
|
)
|
|
@@ -236,7 +283,7 @@ export const task = (
|
|
|
236
283
|
epic: options.epic,
|
|
237
284
|
multiPhase,
|
|
238
285
|
review,
|
|
239
|
-
repo:
|
|
286
|
+
repo: review ? undefined : repo,
|
|
240
287
|
repos: review
|
|
241
288
|
? undefined
|
|
242
289
|
: parseRepositoryReferences(options.references),
|
|
@@ -244,11 +291,33 @@ export const task = (
|
|
|
244
291
|
multiPhase || review
|
|
245
292
|
? undefined
|
|
246
293
|
: (options.branch ?? `task/${id}`),
|
|
247
|
-
base: multiPhase || review ? undefined :
|
|
294
|
+
base: multiPhase || review ? undefined : base,
|
|
295
|
+
purpose: options.purpose as "investigation" | undefined,
|
|
248
296
|
},
|
|
249
297
|
cwd,
|
|
250
298
|
)
|
|
251
|
-
const
|
|
299
|
+
const validation = yield* workbase.validate(cwd)
|
|
300
|
+
const selector = multiPhase
|
|
301
|
+
? `task:${record.id}`
|
|
302
|
+
: `execution-unit:task/${record.id}`
|
|
303
|
+
const evidence = validation.valid
|
|
304
|
+
? yield* buildValidationEvidence({
|
|
305
|
+
startPath: cwd,
|
|
306
|
+
target: selector,
|
|
307
|
+
documentPath: record.path,
|
|
308
|
+
documentRevision: record.revision,
|
|
309
|
+
recalledContext,
|
|
310
|
+
})
|
|
311
|
+
: null
|
|
312
|
+
const { content: _, ...created } = record
|
|
313
|
+
const output = {
|
|
314
|
+
...created,
|
|
315
|
+
selector,
|
|
316
|
+
documentPath: record.path,
|
|
317
|
+
validation,
|
|
318
|
+
evidence,
|
|
319
|
+
recalledContext,
|
|
320
|
+
}
|
|
252
321
|
log(
|
|
253
322
|
options.json
|
|
254
323
|
? JSON.stringify(output, null, 2)
|
|
@@ -256,6 +325,40 @@ export const task = (
|
|
|
256
325
|
)
|
|
257
326
|
return
|
|
258
327
|
}
|
|
328
|
+
case "handoff": {
|
|
329
|
+
const [sourceTaskId, id] = options.args
|
|
330
|
+
if (!sourceTaskId || !id) {
|
|
331
|
+
return yield* Effect.fail(
|
|
332
|
+
new Error("Source task ID and new task ID are required"),
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
if (!options.repo) {
|
|
336
|
+
return yield* Effect.fail(
|
|
337
|
+
new Error("Writable repository is required for task handoff"),
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
const output = yield* tasks.handoff(
|
|
341
|
+
{
|
|
342
|
+
sourceTaskId,
|
|
343
|
+
sourcePhaseId: options.sourcePhase,
|
|
344
|
+
id,
|
|
345
|
+
ticketUrl: options.ticketUrl?.trim() || null,
|
|
346
|
+
description: options.description?.trim() || undefined,
|
|
347
|
+
epic: options.epic,
|
|
348
|
+
repo: options.repo,
|
|
349
|
+
repos: parseRepositoryReferences(options.references),
|
|
350
|
+
branch: options.branch ?? `task/${id}`,
|
|
351
|
+
base: options.base ?? "main",
|
|
352
|
+
},
|
|
353
|
+
cwd,
|
|
354
|
+
)
|
|
355
|
+
log(
|
|
356
|
+
options.json
|
|
357
|
+
? JSON.stringify(output, null, 2)
|
|
358
|
+
: `Created implementation task '${id}' from '${output.source.selector}'`,
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
259
362
|
case "list": {
|
|
260
363
|
const records = yield* tasks.list(cwd)
|
|
261
364
|
const { taskRows } = yield* getWorkViews({
|
|
@@ -442,7 +545,7 @@ export const task = (
|
|
|
442
545
|
default:
|
|
443
546
|
return yield* Effect.fail(
|
|
444
547
|
new Error(
|
|
445
|
-
"Subcommand is required. Available: new, create, list, show, status, update, rename, move, dependency",
|
|
548
|
+
"Subcommand is required. Available: new, create, handoff, list, show, status, update, rename, move, dependency",
|
|
446
549
|
),
|
|
447
550
|
)
|
|
448
551
|
}
|
|
@@ -454,6 +557,7 @@ Usage: agency task <subcommand>
|
|
|
454
557
|
Subcommands:
|
|
455
558
|
new [id] Create a task with guided input
|
|
456
559
|
create <id> Create a task without prompting
|
|
560
|
+
handoff <source> <id> Create implementation work from an investigation
|
|
457
561
|
list List tasks
|
|
458
562
|
show <id> Show a task
|
|
459
563
|
status <id> <status> Set open, working, dropped, or explicit non-PR done
|
|
@@ -474,12 +578,19 @@ Non-PR completion options (status done only):
|
|
|
474
578
|
Create options:
|
|
475
579
|
--ticket-url <url> External ticket URL (optional)
|
|
476
580
|
--description <text> Short description of the task
|
|
581
|
+
--purpose investigation
|
|
582
|
+
Generate an investigation-only task
|
|
477
583
|
--epic <id> Parent epic
|
|
478
584
|
--repo <alias> Writable repository
|
|
479
585
|
--reference <alias>:<ref>
|
|
480
586
|
Read-only repository reference; repeatable
|
|
481
587
|
--branch <name> Working branch (default: task/<id>)
|
|
482
588
|
--base <name> Base branch (default: main)
|
|
589
|
+
--context-repo <alias> Recalled repository; must agree with --repo
|
|
590
|
+
--context-base <name> Recalled base; must agree with --base
|
|
591
|
+
--context-slug <id> Recalled preferred slug; must equal the task ID
|
|
592
|
+
--authoritative-source <path-or-url>
|
|
593
|
+
Authoritative input location; repeatable
|
|
483
594
|
--multi-phase Create a task container for phases
|
|
484
595
|
--review <alias> Create a pinned read-only review task
|
|
485
596
|
--pull-request <url-or-number>
|
|
@@ -488,6 +599,11 @@ Create options:
|
|
|
488
599
|
--work Start work on the new task after creating it
|
|
489
600
|
--auto Pass --auto to work; requires --work
|
|
490
601
|
|
|
602
|
+
Handoff options:
|
|
603
|
+
--source-phase <id> Use a phase document as the source evidence
|
|
604
|
+
--repo <alias> Writable implementation repository
|
|
605
|
+
--reference, --branch, --base, --ticket-url, --description, --epic
|
|
606
|
+
|
|
491
607
|
Update options:
|
|
492
608
|
--ticket-url <url> / --clear-ticket
|
|
493
609
|
--description <text> / --clear-description
|
|
@@ -497,8 +613,8 @@ Update options:
|
|
|
497
613
|
--base <name> Replace the base branch
|
|
498
614
|
--pr-url <url> / --clear-pr
|
|
499
615
|
|
|
500
|
-
Task creation is noninteractive. Single-phase tasks require --repo
|
|
501
|
-
--multi-phase instead for a task container. Guided input is available only
|
|
616
|
+
Task creation is noninteractive. Single-phase tasks require --repo or
|
|
617
|
+
--context-repo; use --multi-phase instead for a task container. Guided input is available only
|
|
502
618
|
through task new, which fails when --no-input is set or no TTY is available.
|
|
503
619
|
|
|
504
620
|
Options:
|
|
@@ -82,6 +82,7 @@ interface HarnessOptions {
|
|
|
82
82
|
readonly phaseStatuses?: Readonly<
|
|
83
83
|
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
84
84
|
>
|
|
85
|
+
readonly taskRepo?: string
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -138,6 +139,16 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
138
139
|
runners: options.runners,
|
|
139
140
|
},
|
|
140
141
|
}),
|
|
142
|
+
repositoryAliases: () => Effect.succeed(["agency"]),
|
|
143
|
+
validate: () =>
|
|
144
|
+
Effect.succeed({
|
|
145
|
+
root: "/workbase",
|
|
146
|
+
issues: [],
|
|
147
|
+
epicCount: 0,
|
|
148
|
+
taskCount: 1,
|
|
149
|
+
phaseCount: 0,
|
|
150
|
+
valid: true,
|
|
151
|
+
}),
|
|
141
152
|
}
|
|
142
153
|
const epics = {
|
|
143
154
|
show: (id: string) =>
|
|
@@ -154,10 +165,11 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
154
165
|
return Effect.succeed({
|
|
155
166
|
id,
|
|
156
167
|
path: `/workbase/tasks/${id}/TASK.md`,
|
|
168
|
+
revision: "a".repeat(64),
|
|
157
169
|
data: options.multiPhaseTasks?.includes(id)
|
|
158
170
|
? { phases: [] }
|
|
159
171
|
: {
|
|
160
|
-
repo: "agency",
|
|
172
|
+
repo: options.taskRepo ?? "agency",
|
|
161
173
|
branch: `task/${id}`,
|
|
162
174
|
base: "main",
|
|
163
175
|
status: taskStatuses[id] ?? options.taskStatus ?? "open",
|
|
@@ -182,6 +194,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
182
194
|
taskId,
|
|
183
195
|
id,
|
|
184
196
|
path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
|
|
197
|
+
revision: "b".repeat(64),
|
|
185
198
|
data: {
|
|
186
199
|
repo: "agency",
|
|
187
200
|
branch: `task/${id}`,
|
|
@@ -246,7 +259,16 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
246
259
|
}
|
|
247
260
|
const fs = {
|
|
248
261
|
isDirectory: (path: string) =>
|
|
249
|
-
Effect.succeed(
|
|
262
|
+
Effect.succeed(
|
|
263
|
+
path === "/workbase/epics" || path === "/workbase/tasks"
|
|
264
|
+
? false
|
|
265
|
+
: (options.existingDirectories?.includes(path) ?? true),
|
|
266
|
+
),
|
|
267
|
+
readFile: (path: string) =>
|
|
268
|
+
Effect.succeed(path.endsWith("agency.json") ? '{"version":2}\n' : ""),
|
|
269
|
+
readDirectory: () => Effect.succeed([]),
|
|
270
|
+
exists: () => Effect.succeed(false),
|
|
271
|
+
realPath: (path: string) => Effect.succeed(path),
|
|
250
272
|
runCommand: (args: readonly string[]) => {
|
|
251
273
|
const cli = args[1]!
|
|
252
274
|
events.push(`probe:${cli}`)
|
|
@@ -306,6 +328,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
306
328
|
Effect.provideService(WorkbaseService, workbase as never),
|
|
307
329
|
Effect.provideService(TaskService, tasks as never),
|
|
308
330
|
Effect.provideService(PhaseService, phases as never),
|
|
331
|
+
Effect.provideService(ReadinessService, readiness as never),
|
|
309
332
|
) as Effect.Effect<void, unknown, never>,
|
|
310
333
|
)
|
|
311
334
|
|
|
@@ -458,7 +481,7 @@ describe("work command", () => {
|
|
|
458
481
|
test("prepares without launching or changing lifecycle status", async () => {
|
|
459
482
|
const harness = createHarness({ existingDirectories: [] })
|
|
460
483
|
|
|
461
|
-
await captureLogs(() =>
|
|
484
|
+
const [output] = await captureLogs(() =>
|
|
462
485
|
harness.runPrepare({
|
|
463
486
|
cwd: "/workbase",
|
|
464
487
|
directory: "example",
|
|
@@ -473,7 +496,43 @@ describe("work command", () => {
|
|
|
473
496
|
expect(harness.materializeOptions[0]).toMatchObject({
|
|
474
497
|
json: true,
|
|
475
498
|
dryRun: true,
|
|
499
|
+
validationAlreadyPerformed: true,
|
|
500
|
+
})
|
|
501
|
+
const result = JSON.parse(output!)
|
|
502
|
+
expect(result.validationEvidence.status).toBe("refreshed")
|
|
503
|
+
expect(result.validationEvidence.reasons).toEqual(["not-supplied"])
|
|
504
|
+
expect(result.kickoff.target).toBe("execution-unit:task/example")
|
|
505
|
+
expect(
|
|
506
|
+
result.kickoff.steps.filter(
|
|
507
|
+
({ id }: { id: string }) => id === "final-context-verification",
|
|
508
|
+
),
|
|
509
|
+
).toHaveLength(1)
|
|
510
|
+
|
|
511
|
+
const [reusedOutput] = await captureLogs(() =>
|
|
512
|
+
harness.runPrepare({
|
|
513
|
+
cwd: "/workbase",
|
|
514
|
+
directory: "example",
|
|
515
|
+
json: true,
|
|
516
|
+
dryRun: true,
|
|
517
|
+
evidence: JSON.stringify(result.validationEvidence.evidence),
|
|
518
|
+
}),
|
|
519
|
+
)
|
|
520
|
+
expect(JSON.parse(reusedOutput!).validationEvidence).toEqual(
|
|
521
|
+
expect.objectContaining({ status: "reused", reasons: [] }),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
const conflicting = createHarness({
|
|
525
|
+
existingDirectories: [],
|
|
526
|
+
taskRepo: "other",
|
|
476
527
|
})
|
|
528
|
+
await expect(
|
|
529
|
+
conflicting.runPrepare({
|
|
530
|
+
cwd: "/workbase",
|
|
531
|
+
directory: "example",
|
|
532
|
+
dryRun: true,
|
|
533
|
+
evidence: JSON.stringify(result.validationEvidence.evidence),
|
|
534
|
+
}),
|
|
535
|
+
).rejects.toThrow("Recalled repository conflicts")
|
|
477
536
|
})
|
|
478
537
|
|
|
479
538
|
test("launches an epic agent from an epic directory", async () => {
|
package/src/commands/work.ts
CHANGED
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
resolveRunnerCommand,
|
|
30
30
|
runnerEnvironment,
|
|
31
31
|
} from "../workbase/runner-command"
|
|
32
|
+
import {
|
|
33
|
+
assessValidationEvidence,
|
|
34
|
+
buildKickoffPlan,
|
|
35
|
+
buildValidationEvidence,
|
|
36
|
+
normalizeRecalledContext,
|
|
37
|
+
readValidationEvidence,
|
|
38
|
+
} from "../workbase/kickoff-contract"
|
|
32
39
|
|
|
33
40
|
export interface WorkOptions extends BaseCommandOptions {
|
|
34
41
|
readonly directory?: string
|
|
@@ -41,6 +48,7 @@ export interface WorkOptions extends BaseCommandOptions {
|
|
|
41
48
|
readonly printCommand?: boolean
|
|
42
49
|
readonly auto?: boolean
|
|
43
50
|
readonly force?: boolean
|
|
51
|
+
readonly evidence?: string
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export type StartWork = (options: WorkOptions) => ReturnType<typeof work>
|
|
@@ -422,6 +430,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
422
430
|
const tasks = yield* TaskService
|
|
423
431
|
const phases = yield* PhaseService
|
|
424
432
|
const worktrees = yield* WorktreeService
|
|
433
|
+
const readiness = yield* ReadinessService
|
|
425
434
|
const { log } = createLoggers(options)
|
|
426
435
|
const cwd = options.cwd ?? process.cwd()
|
|
427
436
|
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
@@ -461,22 +470,111 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
461
470
|
)
|
|
462
471
|
}
|
|
463
472
|
|
|
473
|
+
const task = yield* tasks.show(taskId, root)
|
|
474
|
+
const phase = phaseId
|
|
475
|
+
? yield* phases.show(taskId, phaseId, root)
|
|
476
|
+
: undefined
|
|
477
|
+
if ("phases" in task.data && !phase) {
|
|
478
|
+
return yield* Effect.fail(
|
|
479
|
+
new Error(`Task '${taskId}' has multiple phases; phase ID is required`),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
if (!("phases" in task.data) && phaseId) {
|
|
483
|
+
return yield* Effect.fail(
|
|
484
|
+
new Error(
|
|
485
|
+
`Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
const target = phase
|
|
490
|
+
? `execution-unit:phase/${taskId}/${phase.id}`
|
|
491
|
+
: `execution-unit:task/${taskId}`
|
|
492
|
+
const document = phase ?? task
|
|
493
|
+
const suppliedEvidence = options.evidence
|
|
494
|
+
? yield* readValidationEvidence(options.evidence, cwd)
|
|
495
|
+
: undefined
|
|
496
|
+
if (
|
|
497
|
+
suppliedEvidence?.recalledContext.repo &&
|
|
498
|
+
(!("repo" in document.data) ||
|
|
499
|
+
suppliedEvidence.recalledContext.repo !== document.data.repo)
|
|
500
|
+
) {
|
|
501
|
+
return yield* Effect.fail(
|
|
502
|
+
new Error(
|
|
503
|
+
"Recalled repository conflicts with the current execution unit",
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
if (
|
|
508
|
+
suppliedEvidence?.recalledContext.base &&
|
|
509
|
+
(!("base" in document.data) ||
|
|
510
|
+
suppliedEvidence.recalledContext.base !== document.data.base)
|
|
511
|
+
) {
|
|
512
|
+
return yield* Effect.fail(
|
|
513
|
+
new Error("Recalled base conflicts with the current execution unit"),
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
const assessment = yield* assessValidationEvidence({
|
|
517
|
+
evidence: suppliedEvidence,
|
|
518
|
+
startPath: root,
|
|
519
|
+
target,
|
|
520
|
+
documentPath: document.path,
|
|
521
|
+
documentRevision: document.revision,
|
|
522
|
+
})
|
|
523
|
+
let validation: unknown = { valid: true, source: "evidence" }
|
|
524
|
+
if (assessment.disposition.status === "refreshed") {
|
|
525
|
+
validation = yield* workbase.validate(root)
|
|
526
|
+
if (!(validation as { valid: boolean }).valid && !options.force) {
|
|
527
|
+
return yield* Effect.fail(new Error("Workbase validation failed"))
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
yield* readiness.guardWorkTarget(target, root, options.force)
|
|
464
531
|
const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
|
|
465
532
|
...options,
|
|
466
533
|
dryRun: options.dryRun,
|
|
534
|
+
validationAlreadyPerformed: true,
|
|
535
|
+
})
|
|
536
|
+
const recalledContext =
|
|
537
|
+
suppliedEvidence?.recalledContext ??
|
|
538
|
+
normalizeRecalledContext({
|
|
539
|
+
id: taskId,
|
|
540
|
+
repo: "repo" in document.data ? document.data.repo : undefined,
|
|
541
|
+
base: "base" in document.data ? document.data.base : undefined,
|
|
542
|
+
})
|
|
543
|
+
const evidence = yield* buildValidationEvidence({
|
|
544
|
+
startPath: root,
|
|
545
|
+
target,
|
|
546
|
+
documentPath: document.path,
|
|
547
|
+
documentRevision: document.revision,
|
|
548
|
+
recalledContext,
|
|
467
549
|
})
|
|
550
|
+
const result = {
|
|
551
|
+
...workspace,
|
|
552
|
+
workspace,
|
|
553
|
+
validation,
|
|
554
|
+
validationEvidence: { ...assessment.disposition, evidence },
|
|
555
|
+
kickoff: buildKickoffPlan({
|
|
556
|
+
workbaseRoot: root,
|
|
557
|
+
target,
|
|
558
|
+
taskId,
|
|
559
|
+
phaseId: phase?.id,
|
|
560
|
+
taskPath: task.path,
|
|
561
|
+
phasePath: phase?.path,
|
|
562
|
+
checkoutPath: workspace.writablePath ?? workspace.reviewPath,
|
|
563
|
+
documentRevision: document.revision,
|
|
564
|
+
}),
|
|
565
|
+
}
|
|
468
566
|
if (options.json) {
|
|
469
|
-
log(JSON.stringify(
|
|
567
|
+
log(JSON.stringify(result, null, 2))
|
|
470
568
|
} else {
|
|
471
569
|
log(
|
|
472
|
-
`${workspace.dryRun ? "
|
|
570
|
+
`${workspace.dryRun ? "Kickoff plan" : "Workspace ready"}: ${workspace.writablePath ?? workspace.reviewPath}`,
|
|
473
571
|
)
|
|
474
572
|
}
|
|
475
573
|
})
|
|
476
574
|
|
|
477
575
|
export const help = `
|
|
478
576
|
Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] [--auto]
|
|
479
|
-
agency work prepare [target] [--dry-run] [--json]
|
|
577
|
+
agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
|
|
480
578
|
|
|
481
579
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
482
580
|
interactively. A positional argument resolves as a directory first, then as a task
|
|
@@ -487,6 +585,10 @@ Agency's project plugin; Agency context remains authoritative for writes.
|
|
|
487
585
|
The prepare subcommand resolves and materializes an execution workspace without
|
|
488
586
|
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
489
587
|
changes without fetching, creating branches, or creating worktrees.
|
|
588
|
+
It emits revision-bound validation evidence and an idempotent external-orchestrator
|
|
589
|
+
contract. Evidence is reused only while the target, workbase, configuration, and
|
|
590
|
+
repository mapping remain unchanged. Dynamic readiness and workspace safety checks
|
|
591
|
+
always run.
|
|
490
592
|
|
|
491
593
|
Options:
|
|
492
594
|
--epic <id> Work on an epic
|
|
@@ -500,6 +602,7 @@ Options:
|
|
|
500
602
|
--opencode Require the OpenCode preset
|
|
501
603
|
--claude Require the Claude Code preset
|
|
502
604
|
--force Override readiness; reopen terminal execution units
|
|
605
|
+
--evidence <value> Validation evidence JSON or a path to JSON (prepare only)
|
|
503
606
|
--no-input Never open an interactive selector
|
|
504
607
|
|
|
505
608
|
Without interactive input, provide an explicit workbase or cwd and an entity
|
package/src/graph-schema.test.ts
CHANGED
|
@@ -27,6 +27,14 @@ describe("graph contract", () => {
|
|
|
27
27
|
readiness: { type: "null" },
|
|
28
28
|
aggregate: { type: "null" },
|
|
29
29
|
})
|
|
30
|
+
expect(jsonSchema.$defs.taskHandoff).toMatchObject({
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
required: ["source", "sourceRevision"],
|
|
33
|
+
})
|
|
34
|
+
expect(jsonSchema.$defs.singleTaskData.properties).toMatchObject({
|
|
35
|
+
purpose: { $ref: "#/$defs/taskPurpose" },
|
|
36
|
+
handoff: { $ref: "#/$defs/taskHandoff" },
|
|
37
|
+
})
|
|
30
38
|
})
|
|
31
39
|
|
|
32
40
|
test("streams records that reconstruct graph semantics", () => {
|
package/src/graph-schema.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
PullRequestRecord,
|
|
6
6
|
ClaimRecord,
|
|
7
7
|
ReviewRecord,
|
|
8
|
+
TaskHandoff,
|
|
8
9
|
TaskFrontmatter,
|
|
9
10
|
WorkStatus,
|
|
10
11
|
} from "./workbase/schemas"
|
|
@@ -80,6 +81,10 @@ export const GraphExecutionData = Schema.Union(
|
|
|
80
81
|
phaseId: Schema.optional(Schema.String),
|
|
81
82
|
ticketUrl: Schema.optional(Schema.NullOr(Schema.String)),
|
|
82
83
|
epic: Schema.optional(Schema.String),
|
|
84
|
+
purpose: Schema.optional(
|
|
85
|
+
Schema.Literal("investigation", "implementation"),
|
|
86
|
+
),
|
|
87
|
+
handoff: Schema.optional(TaskHandoff),
|
|
83
88
|
}),
|
|
84
89
|
),
|
|
85
90
|
Schema.Struct({
|
|
@@ -87,6 +92,8 @@ export const GraphExecutionData = Schema.Union(
|
|
|
87
92
|
ticketUrl: Schema.NullOr(Schema.String),
|
|
88
93
|
description: Schema.optional(Schema.String),
|
|
89
94
|
epic: Schema.optional(Schema.String),
|
|
95
|
+
purpose: Schema.optional(Schema.Literal("investigation", "implementation")),
|
|
96
|
+
handoff: Schema.optional(TaskHandoff),
|
|
90
97
|
review: ReviewRecord,
|
|
91
98
|
status: WorkStatus,
|
|
92
99
|
claim: Schema.optional(ClaimRecord),
|
|
@@ -734,6 +734,11 @@ describe("ArchiveService", () => {
|
|
|
734
734
|
repo: "agency",
|
|
735
735
|
branch: "task/child",
|
|
736
736
|
base: "main",
|
|
737
|
+
purpose: "implementation",
|
|
738
|
+
handoff: {
|
|
739
|
+
source: { kind: "task", taskId: "investigation" },
|
|
740
|
+
sourceRevision: "a".repeat(64),
|
|
741
|
+
},
|
|
737
742
|
},
|
|
738
743
|
root,
|
|
739
744
|
),
|
|
@@ -787,6 +792,18 @@ describe("ArchiveService", () => {
|
|
|
787
792
|
),
|
|
788
793
|
)
|
|
789
794
|
expect(epic.data.tasks).toEqual([{ id: "child" }])
|
|
795
|
+
const restoredTask = await runTestEffect(
|
|
796
|
+
TaskService.pipe(
|
|
797
|
+
Effect.flatMap((service) => service.show("child", root)),
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
expect(restoredTask.data).toMatchObject({
|
|
801
|
+
purpose: "implementation",
|
|
802
|
+
handoff: {
|
|
803
|
+
source: { kind: "task", taskId: "investigation" },
|
|
804
|
+
sourceRevision: "a".repeat(64),
|
|
805
|
+
},
|
|
806
|
+
})
|
|
790
807
|
const provenance = await Bun.file(
|
|
791
808
|
join(root, "tasks/child/.agency-lifecycle.json"),
|
|
792
809
|
).json()
|
|
@@ -1040,7 +1057,7 @@ describe("ArchiveService", () => {
|
|
|
1040
1057
|
),
|
|
1041
1058
|
),
|
|
1042
1059
|
),
|
|
1043
|
-
).rejects.toThrow("is archived;
|
|
1060
|
+
).rejects.toThrow("is archived; explicit creation requires a different ID")
|
|
1044
1061
|
})
|
|
1045
1062
|
|
|
1046
1063
|
test("does not remove worktrees when another lifecycle mutation holds the lock", async () => {
|