@markjaquith/agency 2.15.0 → 2.17.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 +35 -1
- package/cli.ts +40 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +34 -0
- package/src/cli-parser.ts +38 -4
- package/src/cli.test.ts +50 -0
- package/src/commands/next.ts +64 -0
- package/src/commands/pr.ts +2 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/sync.ts +36 -0
- package/src/commands/work.test.ts +84 -0
- package/src/commands/work.ts +25 -2
- package/src/protocol.ts +11 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/GraphService.test.ts +36 -0
- package/src/services/GraphService.ts +8 -1
- package/src/services/PullRequestService.test.ts +20 -1
- package/src/services/PullRequestService.ts +14 -1
- package/src/services/ReadinessService.test.ts +223 -0
- package/src/services/ReadinessService.ts +230 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/services/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +4 -0
package/README.md
CHANGED
|
@@ -304,6 +304,40 @@ inspection are opt-in include layers.
|
|
|
304
304
|
`end` record with counts. Combining the metadata with the streamed node and edge
|
|
305
305
|
records reconstructs the same result as `--json`.
|
|
306
306
|
|
|
307
|
+
### Next Ready Work
|
|
308
|
+
|
|
309
|
+
`agency next` lists ready execution units in descending unlock priority, with
|
|
310
|
+
their epic and task context. `agency next --select` returns only the highest-
|
|
311
|
+
priority ready unit in human output.
|
|
312
|
+
|
|
313
|
+
`agency next --json` returns the same ranked `ready` set plus every `excluded`
|
|
314
|
+
execution unit. Excluded entries retain status, terminal state, `blockedBy`, and
|
|
315
|
+
detailed dependency, validation, or status blockers for orchestrators.
|
|
316
|
+
|
|
317
|
+
`agency work` and `agency pr create` consult this shared readiness model before
|
|
318
|
+
materializing or pushing. Blocked, done, and dropped targets are rejected unless
|
|
319
|
+
`--force` is supplied explicitly. PR creation permits active `working` and
|
|
320
|
+
`delegated` targets when they have no dependency or validation blocker.
|
|
321
|
+
|
|
322
|
+
### Reconciliation
|
|
323
|
+
|
|
324
|
+
`agency sync` compares every execution declaration with local branch and worktree
|
|
325
|
+
registration, writable and reference checkout dirtiness, resolved reference
|
|
326
|
+
commits, claim expiry, and GitHub pull request and merge state. It reports
|
|
327
|
+
structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
328
|
+
default and `--dry-run` modes are observational.
|
|
329
|
+
|
|
330
|
+
`agency sync --apply` performs only these safe transitions:
|
|
331
|
+
|
|
332
|
+
- materialize missing checkouts when no registration, branch, or path conflicts;
|
|
333
|
+
- release an active claim only after its declared expiry has passed;
|
|
334
|
+
- record a single PR whose head and base match the declaration; and
|
|
335
|
+
- mark work done after its authoritative PR is merged and no active claim remains.
|
|
336
|
+
|
|
337
|
+
Apply never modifies dirty checkouts, moves worktrees, switches branches, resets
|
|
338
|
+
reference commits, chooses among multiple PRs, or bypasses active claims. Those
|
|
339
|
+
conditions remain visible in `warnings` or `unresolved` with a suggested action.
|
|
340
|
+
|
|
307
341
|
### Workbase and Repositories
|
|
308
342
|
|
|
309
343
|
```text
|
|
@@ -324,7 +358,7 @@ repository. Alias names are then used by all documents and commands.
|
|
|
324
358
|
|
|
325
359
|
Commands that print Agency-owned results accept `--json`, including initialization,
|
|
326
360
|
integration inspection/sync, repository mutations, entity creation/list/show,
|
|
327
|
-
status, validation, graph export, and PR creation.
|
|
361
|
+
status, validation, graph export, reconciliation, and PR creation.
|
|
328
362
|
|
|
329
363
|
### Epics
|
|
330
364
|
|
package/cli.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { status, help as statusHelp } from "./src/commands/status"
|
|
|
10
10
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
11
11
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
12
12
|
import { graph, help as graphHelp } from "./src/commands/graph"
|
|
13
|
+
import { next, help as nextHelp } from "./src/commands/next"
|
|
14
|
+
import { sync, help as syncHelp } from "./src/commands/sync"
|
|
13
15
|
import { repo, help as repoHelp } from "./src/commands/repo"
|
|
14
16
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
15
17
|
import { phase, help as phaseHelp } from "./src/commands/phase"
|
|
@@ -33,6 +35,8 @@ import { IntegrationService } from "./src/services/IntegrationService"
|
|
|
33
35
|
import { ContextService } from "./src/services/ContextService"
|
|
34
36
|
import { GraphService } from "./src/services/GraphService"
|
|
35
37
|
import { ClaimService } from "./src/services/ClaimService"
|
|
38
|
+
import { SyncService } from "./src/services/SyncService"
|
|
39
|
+
import { ReadinessService } from "./src/services/ReadinessService"
|
|
36
40
|
import {
|
|
37
41
|
claimCommand,
|
|
38
42
|
claimHelp,
|
|
@@ -61,6 +65,8 @@ const CliLayer = Layer.mergeAll(
|
|
|
61
65
|
ContextService.Default,
|
|
62
66
|
GraphService.Default,
|
|
63
67
|
ClaimService.Default,
|
|
68
|
+
SyncService.Default,
|
|
69
|
+
ReadinessService.Default,
|
|
64
70
|
)
|
|
65
71
|
|
|
66
72
|
/**
|
|
@@ -210,6 +216,7 @@ const commands: Record<string, Command> = {
|
|
|
210
216
|
taskId: args[1],
|
|
211
217
|
phaseId: args[2],
|
|
212
218
|
draft: options.draft,
|
|
219
|
+
force: options.force,
|
|
213
220
|
json: options.json,
|
|
214
221
|
silent: options.silent,
|
|
215
222
|
verbose: options.verbose,
|
|
@@ -348,11 +355,25 @@ const commands: Record<string, Command> = {
|
|
|
348
355
|
verbose: options.verbose,
|
|
349
356
|
opencode: options.opencode,
|
|
350
357
|
claude: options.claude,
|
|
358
|
+
force: options.force,
|
|
351
359
|
inputAllowed: options.inputAllowed,
|
|
352
360
|
}),
|
|
353
361
|
)
|
|
354
362
|
},
|
|
355
363
|
},
|
|
364
|
+
next: {
|
|
365
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
366
|
+
if (options.help) return console.log(nextHelp)
|
|
367
|
+
await runCommand(
|
|
368
|
+
next({
|
|
369
|
+
select: options.select,
|
|
370
|
+
json: options.json,
|
|
371
|
+
silent: options.silent,
|
|
372
|
+
verbose: options.verbose,
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
},
|
|
376
|
+
},
|
|
356
377
|
status: {
|
|
357
378
|
run: async (_args: string[], options: Record<string, any>) => {
|
|
358
379
|
if (options.help) {
|
|
@@ -424,6 +445,23 @@ const commands: Record<string, Command> = {
|
|
|
424
445
|
)
|
|
425
446
|
},
|
|
426
447
|
},
|
|
448
|
+
sync: {
|
|
449
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
450
|
+
if (options.help) {
|
|
451
|
+
console.log(syncHelp)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
await runCommand(
|
|
455
|
+
sync({
|
|
456
|
+
apply: options.apply,
|
|
457
|
+
dryRun: options["dry-run"],
|
|
458
|
+
json: options.json,
|
|
459
|
+
silent: options.silent,
|
|
460
|
+
verbose: options.verbose,
|
|
461
|
+
}),
|
|
462
|
+
)
|
|
463
|
+
},
|
|
464
|
+
},
|
|
427
465
|
}
|
|
428
466
|
|
|
429
467
|
function showMainHelp() {
|
|
@@ -444,12 +482,14 @@ Commands:
|
|
|
444
482
|
archive <type> Archive a work item
|
|
445
483
|
task <subcommand> Manage tasks
|
|
446
484
|
work [directory|task] Work on an epic, task, or phase
|
|
485
|
+
next List or select ready execution units
|
|
447
486
|
pr create Create a pull request for an execution unit
|
|
448
487
|
repo <subcommand> Manage workbase repositories
|
|
449
488
|
status Show status for the current workbase
|
|
450
489
|
validate [path] Validate a workbase
|
|
451
490
|
context [target] Return complete target context
|
|
452
491
|
graph Export the complete workbase graph
|
|
492
|
+
sync Reconcile declarations with external state
|
|
453
493
|
|
|
454
494
|
Global Options:
|
|
455
495
|
-h, --help Show help for a command
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -141,6 +141,8 @@ describe("strict CLI parsing", () => {
|
|
|
141
141
|
[["validate", "one", "two"], "agency validate"],
|
|
142
142
|
[["context", "one", "two"], "agency context"],
|
|
143
143
|
[["graph", "extra"], "agency graph"],
|
|
144
|
+
[["next", "extra"], "agency next"],
|
|
145
|
+
[["sync", "extra"], "agency sync"],
|
|
144
146
|
[
|
|
145
147
|
[
|
|
146
148
|
"claim",
|
|
@@ -191,6 +193,38 @@ describe("strict CLI parsing", () => {
|
|
|
191
193
|
}
|
|
192
194
|
})
|
|
193
195
|
|
|
196
|
+
test("parses readiness selection and explicit guard overrides", () => {
|
|
197
|
+
expect(parseCli(["next", "--select", "--json"])).toMatchObject({
|
|
198
|
+
commandName: "next",
|
|
199
|
+
values: { select: true, json: true },
|
|
200
|
+
})
|
|
201
|
+
expect(parseCli(["work", "example", "--force"])).toMatchObject({
|
|
202
|
+
commandName: "work",
|
|
203
|
+
values: { force: true },
|
|
204
|
+
})
|
|
205
|
+
expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
|
|
206
|
+
commandName: "pr",
|
|
207
|
+
values: { force: true },
|
|
208
|
+
})
|
|
209
|
+
expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
|
|
210
|
+
"cannot be combined",
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("parses reconciliation modes and rejects conflicting modes", () => {
|
|
215
|
+
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
216
|
+
commandName: "sync",
|
|
217
|
+
values: { "dry-run": true, json: true },
|
|
218
|
+
})
|
|
219
|
+
expect(parseCli(["sync", "--apply"])).toMatchObject({
|
|
220
|
+
commandName: "sync",
|
|
221
|
+
values: { apply: true },
|
|
222
|
+
})
|
|
223
|
+
expect(() => parseCli(["sync", "--dry-run", "--apply"])).toThrow(
|
|
224
|
+
"cannot be combined",
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
194
228
|
test("validates revision-guarded claim operations", () => {
|
|
195
229
|
const revision = "0".repeat(64)
|
|
196
230
|
expect(
|
package/src/cli-parser.ts
CHANGED
|
@@ -319,6 +319,21 @@ const commands = {
|
|
|
319
319
|
required: ["session-id", "revision", "outcome"],
|
|
320
320
|
},
|
|
321
321
|
},
|
|
322
|
+
sync: {
|
|
323
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
324
|
+
options: {
|
|
325
|
+
...outputOptions,
|
|
326
|
+
"dry-run": { type: "boolean" },
|
|
327
|
+
apply: { type: "boolean" },
|
|
328
|
+
},
|
|
329
|
+
command: {
|
|
330
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
331
|
+
minArgs: 0,
|
|
332
|
+
maxArgs: 0,
|
|
333
|
+
options: ["dry-run", "apply", "json"],
|
|
334
|
+
conflicts: [["dry-run", "apply"]],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
322
337
|
archive: {
|
|
323
338
|
usage: "agency archive <epic|task|phase>",
|
|
324
339
|
options: outputOptions,
|
|
@@ -353,13 +368,14 @@ const commands = {
|
|
|
353
368
|
epic: { type: "string" },
|
|
354
369
|
opencode: { type: "boolean" },
|
|
355
370
|
claude: { type: "boolean" },
|
|
371
|
+
force: { type: "boolean" },
|
|
356
372
|
},
|
|
357
373
|
command: {
|
|
358
374
|
usage:
|
|
359
375
|
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
360
376
|
minArgs: 0,
|
|
361
377
|
maxArgs: 2,
|
|
362
|
-
options: ["json", "dry-run", "epic", "opencode", "claude"],
|
|
378
|
+
options: ["json", "dry-run", "epic", "opencode", "claude", "force"],
|
|
363
379
|
conflicts: [
|
|
364
380
|
["opencode", "claude"],
|
|
365
381
|
["epic", "$positional"],
|
|
@@ -371,16 +387,31 @@ const commands = {
|
|
|
371
387
|
options: {
|
|
372
388
|
...outputOptions,
|
|
373
389
|
draft: { type: "boolean" },
|
|
390
|
+
force: { type: "boolean" },
|
|
374
391
|
},
|
|
375
392
|
subcommands: {
|
|
376
393
|
create: {
|
|
377
|
-
usage:
|
|
394
|
+
usage:
|
|
395
|
+
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
378
396
|
minArgs: 1,
|
|
379
397
|
maxArgs: 2,
|
|
380
|
-
options: ["draft", "json"],
|
|
398
|
+
options: ["draft", "force", "json"],
|
|
381
399
|
},
|
|
382
400
|
},
|
|
383
401
|
},
|
|
402
|
+
next: {
|
|
403
|
+
usage: "agency next [--select] [--json]",
|
|
404
|
+
options: {
|
|
405
|
+
...outputOptions,
|
|
406
|
+
select: { type: "boolean" },
|
|
407
|
+
},
|
|
408
|
+
command: {
|
|
409
|
+
usage: "agency next [--select] [--json]",
|
|
410
|
+
minArgs: 0,
|
|
411
|
+
maxArgs: 0,
|
|
412
|
+
options: ["select", "json"],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
384
415
|
status: {
|
|
385
416
|
usage: "agency status [--json]",
|
|
386
417
|
options: outputOptions,
|
|
@@ -729,7 +760,10 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
729
760
|
if (
|
|
730
761
|
(!preparing && commandPositionals.length > 1) ||
|
|
731
762
|
(preparing &&
|
|
732
|
-
(parsed.values.epic ||
|
|
763
|
+
(parsed.values.epic ||
|
|
764
|
+
parsed.values.opencode ||
|
|
765
|
+
parsed.values.claude ||
|
|
766
|
+
parsed.values.force))
|
|
733
767
|
) {
|
|
734
768
|
throw usageError(
|
|
735
769
|
preparing
|
package/src/cli.test.ts
CHANGED
|
@@ -245,6 +245,7 @@ describe("CLI", () => {
|
|
|
245
245
|
["validate", "Usage: agency validate"],
|
|
246
246
|
["context", "Usage: agency context"],
|
|
247
247
|
["graph", "Usage: agency graph"],
|
|
248
|
+
["next", "Usage: agency next"],
|
|
248
249
|
] as const) {
|
|
249
250
|
const result = await runCli([command, "--help"])
|
|
250
251
|
expect(result.exitCode).toBe(0)
|
|
@@ -264,6 +265,55 @@ describe("CLI", () => {
|
|
|
264
265
|
|
|
265
266
|
const after = await runCli(["status", "--silent"], root)
|
|
266
267
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
268
|
+
}, 10_000)
|
|
269
|
+
|
|
270
|
+
test("lists ready work and exposes excluded blockers through one result", async () => {
|
|
271
|
+
const root = await createTempDir()
|
|
272
|
+
tempDirs.push(root)
|
|
273
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
274
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
275
|
+
for (const id of ["ready", "finished"]) {
|
|
276
|
+
parseJson(
|
|
277
|
+
await runCli(
|
|
278
|
+
["task", "create", id, "--repo", "agency", "--json"],
|
|
279
|
+
root,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
parseJson(
|
|
284
|
+
await runCli(["task", "status", "finished", "done", "--json"], root),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const human = await runCli(["next"], root)
|
|
288
|
+
expect(human).toMatchObject({ exitCode: 0, stderr: "" })
|
|
289
|
+
expect(human.stdout).toContain("1. task/ready")
|
|
290
|
+
expect(human.stdout).not.toContain("task/finished")
|
|
291
|
+
|
|
292
|
+
const result = parseJson(await runCli(["next", "--select", "--json"], root))
|
|
293
|
+
expect(result.selected).toMatchObject({ key: "task/ready", rank: 1 })
|
|
294
|
+
expect(result.ready.map((item: any) => item.key)).toEqual(["task/ready"])
|
|
295
|
+
expect(result.excluded).toMatchObject([
|
|
296
|
+
{
|
|
297
|
+
key: "task/finished",
|
|
298
|
+
status: "done",
|
|
299
|
+
terminal: true,
|
|
300
|
+
blockers: [{ kind: "status", reason: "Task status is done" }],
|
|
301
|
+
},
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
305
|
+
expect(blockedPr.exitCode).toBe(1)
|
|
306
|
+
expect(blockedPr.stderr).toBe("")
|
|
307
|
+
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
308
|
+
ok: false,
|
|
309
|
+
error: {
|
|
310
|
+
code: "EXECUTION_BLOCKED",
|
|
311
|
+
fields: {
|
|
312
|
+
status: "done",
|
|
313
|
+
blockers: [{ kind: "status", reason: "Task status is done" }],
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
})
|
|
267
317
|
})
|
|
268
318
|
|
|
269
319
|
test("reports and synchronizes managed integration files", async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface NextOptions extends BaseCommandOptions {
|
|
7
|
+
readonly select?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const context = (item: {
|
|
11
|
+
readonly parent: { readonly taskId?: string; readonly epicId?: string }
|
|
12
|
+
}) =>
|
|
13
|
+
[
|
|
14
|
+
item.parent.epicId ? `epic ${item.parent.epicId}` : undefined,
|
|
15
|
+
item.parent.taskId ? `task ${item.parent.taskId}` : undefined,
|
|
16
|
+
]
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.join(" / ")
|
|
19
|
+
|
|
20
|
+
export const next = (options: NextOptions = {}) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const readiness = yield* ReadinessService
|
|
23
|
+
const { log } = createLoggers(options)
|
|
24
|
+
const result = yield* readiness.getNext(
|
|
25
|
+
options.cwd ?? process.cwd(),
|
|
26
|
+
options.select,
|
|
27
|
+
)
|
|
28
|
+
if (options.json) {
|
|
29
|
+
log(JSON.stringify(result, null, 2))
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (options.select) {
|
|
33
|
+
if (!result.selected) {
|
|
34
|
+
log("No execution units are ready.")
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const parent = context(result.selected)
|
|
38
|
+
log(
|
|
39
|
+
`${result.selected.key}${parent ? ` (${parent})` : ""} - priority ${result.selected.priority.dependentCount}`,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (result.ready.length === 0) {
|
|
44
|
+
log("No execution units are ready.")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
for (const item of result.ready) {
|
|
48
|
+
const parent = context(item)
|
|
49
|
+
log(
|
|
50
|
+
`${item.rank}. ${item.key}${parent ? ` (${parent})` : ""} - priority ${item.priority.dependentCount}`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const help = `
|
|
56
|
+
Usage: agency next [--select] [--json]
|
|
57
|
+
|
|
58
|
+
List ready execution units in priority order or select the highest-priority unit.
|
|
59
|
+
Structured output also includes excluded units and their blockers.
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
--select Return only the highest-priority ready unit in human output
|
|
63
|
+
--json Output ready and excluded execution units as JSON
|
|
64
|
+
`
|
package/src/commands/pr.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface PrOptions extends BaseCommandOptions {
|
|
|
8
8
|
readonly taskId?: string
|
|
9
9
|
readonly phaseId?: string
|
|
10
10
|
readonly draft?: boolean
|
|
11
|
+
readonly force?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export const pr = (options: PrOptions) =>
|
|
@@ -37,5 +38,6 @@ task or phase document.
|
|
|
37
38
|
|
|
38
39
|
Options:
|
|
39
40
|
--draft Create a draft pull request
|
|
41
|
+
--force Override readiness and terminal-state guards
|
|
40
42
|
--json Output the pull request URL as JSON
|
|
41
43
|
`
|
|
@@ -13,6 +13,7 @@ import { task } from "./task"
|
|
|
13
13
|
import { validate } from "./validate"
|
|
14
14
|
import { context } from "./context"
|
|
15
15
|
import { graph } from "./graph"
|
|
16
|
+
import { next } from "./next"
|
|
16
17
|
|
|
17
18
|
const write = async (root: string, path: string, content: string) => {
|
|
18
19
|
const fullPath = join(root, path)
|
|
@@ -145,6 +146,7 @@ status: open
|
|
|
145
146
|
}),
|
|
146
147
|
)
|
|
147
148
|
await runTestEffect(graph({ cwd: root, silent: true }))
|
|
149
|
+
await runTestEffect(next({ cwd: root, silent: true }))
|
|
148
150
|
|
|
149
151
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
150
152
|
expect(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { SyncService } from "../services/SyncService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface SyncCommandOptions extends BaseCommandOptions {
|
|
7
|
+
readonly apply?: boolean
|
|
8
|
+
readonly dryRun?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const sync = (options: SyncCommandOptions = {}) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const service = yield* SyncService
|
|
14
|
+
const { log } = createLoggers(options)
|
|
15
|
+
const result = yield* service.reconcile({
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
apply: options.apply === true,
|
|
18
|
+
})
|
|
19
|
+
log(JSON.stringify(result, null, 2))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const help = `
|
|
23
|
+
Usage: agency sync [--dry-run | --apply] [--json]
|
|
24
|
+
|
|
25
|
+
Compare declared execution state with Git worktrees, branches, references, claims,
|
|
26
|
+
and GitHub pull requests. Dry-run is the default.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--dry-run Report planned safe transitions without changing state
|
|
30
|
+
--apply Apply safe reconciliation transitions
|
|
31
|
+
--json Output one versioned machine result
|
|
32
|
+
|
|
33
|
+
Apply may materialize unambiguous missing checkouts, release expired claims,
|
|
34
|
+
record a uniquely matched PR, and mark merged work done. Dirty, stale, or
|
|
35
|
+
conflicting checkouts are always left unresolved.
|
|
36
|
+
`
|
|
@@ -7,6 +7,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
7
7
|
import { PhaseService } from "../services/PhaseService"
|
|
8
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
9
9
|
import { ClaimService } from "../services/ClaimService"
|
|
10
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
10
11
|
import { captureErrors, captureLogs } from "../test-utils"
|
|
11
12
|
import { work, workPrepare } from "./work"
|
|
12
13
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
@@ -55,6 +56,8 @@ interface HarnessOptions {
|
|
|
55
56
|
readonly outsideWorkbase?: boolean
|
|
56
57
|
readonly registeredWorkbases?: readonly string[]
|
|
57
58
|
readonly existingDirectories?: readonly string[]
|
|
59
|
+
readonly guardError?: Error
|
|
60
|
+
readonly readyTargetIds?: readonly string[]
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -63,6 +66,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
63
66
|
const statusUpdates: string[] = []
|
|
64
67
|
const shownTasks: string[] = []
|
|
65
68
|
const progressUpdates: string[] = []
|
|
69
|
+
const guards: Array<{ target: string; override?: boolean }> = []
|
|
66
70
|
const launches: Array<{
|
|
67
71
|
cli: string
|
|
68
72
|
args: readonly string[]
|
|
@@ -167,6 +171,34 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
171
|
return Effect.succeed({ revision: "1".repeat(64) })
|
|
168
172
|
},
|
|
169
173
|
}
|
|
174
|
+
const readiness = {
|
|
175
|
+
getReadyWorkTargetIds: () =>
|
|
176
|
+
Effect.succeed(
|
|
177
|
+
new Set(
|
|
178
|
+
options.readyTargetIds ?? [
|
|
179
|
+
...(options.epicRecords ?? []).map(
|
|
180
|
+
(record: any) => `epic:${record.id}`,
|
|
181
|
+
),
|
|
182
|
+
...(options.taskRecords ?? []).map((record: any) =>
|
|
183
|
+
"phases" in record.data
|
|
184
|
+
? `task:${record.id}`
|
|
185
|
+
: `execution-unit:task/${record.id}`,
|
|
186
|
+
),
|
|
187
|
+
...(options.phaseRecords ?? []).map(
|
|
188
|
+
(record: any) =>
|
|
189
|
+
`execution-unit:phase/${record.taskId}/${record.id}`,
|
|
190
|
+
),
|
|
191
|
+
],
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
guardWorkTarget: (target: string, _root: string, override?: boolean) => {
|
|
195
|
+
if (options.guardError || override) events.push("guard")
|
|
196
|
+
guards.push({ target, override })
|
|
197
|
+
return options.guardError && !override
|
|
198
|
+
? Effect.fail(options.guardError)
|
|
199
|
+
: Effect.void
|
|
200
|
+
},
|
|
201
|
+
}
|
|
170
202
|
const fs = {
|
|
171
203
|
isDirectory: (path: string) =>
|
|
172
204
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
@@ -206,6 +238,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
206
238
|
Effect.provideService(TaskService, tasks as never),
|
|
207
239
|
Effect.provideService(PhaseService, phases as never),
|
|
208
240
|
Effect.provideService(ClaimService, claims as never),
|
|
241
|
+
Effect.provideService(ReadinessService, readiness as never),
|
|
209
242
|
) as Effect.Effect<void, unknown, never>,
|
|
210
243
|
)
|
|
211
244
|
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
@@ -227,12 +260,63 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
227
260
|
statusUpdates,
|
|
228
261
|
shownTasks,
|
|
229
262
|
progressUpdates,
|
|
263
|
+
guards,
|
|
230
264
|
run,
|
|
231
265
|
runPrepare,
|
|
232
266
|
}
|
|
233
267
|
}
|
|
234
268
|
|
|
235
269
|
describe("work command", () => {
|
|
270
|
+
test("guards execution targets before materialization and honors --force", async () => {
|
|
271
|
+
const blocked = createHarness({ guardError: new Error("blocked") })
|
|
272
|
+
await expect(
|
|
273
|
+
blocked.run({ taskId: "example", opencode: true }),
|
|
274
|
+
).rejects.toThrow("blocked")
|
|
275
|
+
expect(blocked.events).toEqual(["guard"])
|
|
276
|
+
expect(blocked.guards).toEqual([
|
|
277
|
+
{ target: "execution-unit:task/example", override: undefined },
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
const forced = createHarness({ guardError: new Error("blocked") })
|
|
281
|
+
await forced.run({ taskId: "example", opencode: true, force: true })
|
|
282
|
+
expect(forced.events).toEqual([
|
|
283
|
+
"guard",
|
|
284
|
+
"materialize",
|
|
285
|
+
"probe:opencode",
|
|
286
|
+
"launch:opencode",
|
|
287
|
+
])
|
|
288
|
+
expect(forced.guards[0]).toEqual({
|
|
289
|
+
target: "execution-unit:task/example",
|
|
290
|
+
override: true,
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("offers only graph-ready targets to the interactive chooser", async () => {
|
|
295
|
+
const harness = createHarness({
|
|
296
|
+
taskRecords: [
|
|
297
|
+
{
|
|
298
|
+
id: "ready",
|
|
299
|
+
path: "/workbase/tasks/ready/TASK.md",
|
|
300
|
+
data: { status: "open" },
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "blocked",
|
|
304
|
+
path: "/workbase/tasks/blocked/TASK.md",
|
|
305
|
+
data: { status: "open" },
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
readyTargetIds: ["execution-unit:task/ready"],
|
|
309
|
+
})
|
|
310
|
+
let labels: readonly string[] = []
|
|
311
|
+
const pick: PickWorkTarget = (choices) => {
|
|
312
|
+
labels = choices.map((choice) => choice.plainLabel)
|
|
313
|
+
return Effect.succeed(null)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await harness.run({ cwd: "/workbase" }, pick)
|
|
317
|
+
expect(labels).toEqual(["[open] task ready"])
|
|
318
|
+
})
|
|
319
|
+
|
|
236
320
|
test("prepares without launching or changing lifecycle status", async () => {
|
|
237
321
|
const harness = createHarness({ existingDirectories: [] })
|
|
238
322
|
|