@markjaquith/agency 2.11.0 → 2.13.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 +14 -4
- package/cli.ts +6 -3
- package/index.ts +1 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +6 -1
- package/src/cli-parser.test.ts +16 -0
- package/src/cli-parser.ts +29 -4
- package/src/cli.test.ts +70 -0
- package/src/commands/work.test.ts +39 -1
- package/src/commands/work.ts +60 -0
- package/src/graph-schema.test.ts +7 -0
- package/src/graph-schema.ts +2 -0
- package/src/readiness.test.ts +47 -0
- package/src/readiness.ts +58 -0
- package/src/services/ContextService.ts +6 -32
- package/src/services/GraphService.test.ts +55 -1
- package/src/services/GraphService.ts +14 -40
- package/src/services/PhaseService.ts +6 -0
- package/src/services/TaskPhaseService.test.ts +43 -0
- package/src/services/TaskService.ts +6 -0
- package/src/services/WorktreeService.test.ts +82 -7
- package/src/services/WorktreeService.ts +199 -41
- package/src/utils/command.ts +1 -0
package/README.md
CHANGED
|
@@ -262,9 +262,10 @@ Nodes use stable IDs (`epic:<id>`, `task:<id>`, `phase:<task>/<phase>`,
|
|
|
262
262
|
`repository:<alias>`, and `execution-unit:<kind>/<id>`). Typed edges are `owns`,
|
|
263
263
|
`depends_on`, `writes`, and `references`.
|
|
264
264
|
|
|
265
|
-
Every work node includes status, readiness,
|
|
266
|
-
aggregate progress. Only `done` satisfies
|
|
267
|
-
|
|
265
|
+
Every work node includes status, readiness, `blockedBy`, detailed blockers,
|
|
266
|
+
terminal state, reverse dependents, and aggregate progress. Only `done` satisfies
|
|
267
|
+
a dependency; `dropped` is terminal but does not satisfy dependents. The graph
|
|
268
|
+
summary counts the statuses of all execution units, independent of filters.
|
|
268
269
|
|
|
269
270
|
```text
|
|
270
271
|
agency graph [--json | --jsonl] [--ready | --blocked]
|
|
@@ -390,7 +391,9 @@ Single-phase tasks and phases store status in YAML. New execution units start
|
|
|
390
391
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
391
392
|
before launch. Use the status subcommands to mark work `delegated`, `done`,
|
|
392
393
|
`dropped`, or open it again. The interactive work selector displays status
|
|
393
|
-
markers before execution units.
|
|
394
|
+
markers before execution units. Open, working, and delegated work may transition
|
|
395
|
+
to any status. Done and dropped work are terminal and may only remain unchanged
|
|
396
|
+
or transition to open; reopen terminal work before changing its outcome.
|
|
394
397
|
|
|
395
398
|
### Archive
|
|
396
399
|
|
|
@@ -409,6 +412,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
|
|
|
409
412
|
|
|
410
413
|
```text
|
|
411
414
|
agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
|
|
415
|
+
agency work prepare [target] [--dry-run] [--json]
|
|
412
416
|
agency pr create <task-id> [phase-id] [--draft] [--json]
|
|
413
417
|
```
|
|
414
418
|
|
|
@@ -418,6 +422,12 @@ workbase, Agency first presents the registered workbases, then the selected
|
|
|
418
422
|
workbase's hierarchy. If `fzf` is not installed, Agency prints the available
|
|
419
423
|
choices and asks for an explicit directory.
|
|
420
424
|
|
|
425
|
+
`agency work prepare` resolves an execution unit and creates or reuses its
|
|
426
|
+
writable and reference worktrees without launching an agent or changing status.
|
|
427
|
+
Its JSON result includes document and checkout paths, resolved commits, actions,
|
|
428
|
+
and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
|
|
429
|
+
changes without applying them.
|
|
430
|
+
|
|
421
431
|
Epic and multi-phase task targets launch orchestration agents beside their
|
|
422
432
|
documents. Single-phase tasks and phases fetch repositories, create or reuse
|
|
423
433
|
worktrees under `code/`, and launch an execution agent in the writable checkout
|
package/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parseCli } from "./src/cli-parser"
|
|
|
5
5
|
import { init, help as initHelp } from "./src/commands/init"
|
|
6
6
|
import { task, help as taskHelp } from "./src/commands/task"
|
|
7
7
|
import { pr, help as prHelp } from "./src/commands/pr"
|
|
8
|
-
import { work, help as workHelp } from "./src/commands/work"
|
|
8
|
+
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
9
9
|
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"
|
|
@@ -274,10 +274,13 @@ const commands: Record<string, Command> = {
|
|
|
274
274
|
console.log(workHelp)
|
|
275
275
|
return
|
|
276
276
|
}
|
|
277
|
+
const preparing = args[0] === "prepare"
|
|
277
278
|
await runCommand(
|
|
278
|
-
work({
|
|
279
|
-
directory: args[0],
|
|
279
|
+
(preparing ? workPrepare : work)({
|
|
280
|
+
directory: args[preparing ? 1 : 0],
|
|
280
281
|
epicId: options.epic,
|
|
282
|
+
json: options.json,
|
|
283
|
+
dryRun: options["dry-run"],
|
|
281
284
|
silent: options.silent,
|
|
282
285
|
verbose: options.verbose,
|
|
283
286
|
opencode: options.opencode,
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -125,10 +125,15 @@
|
|
|
125
125
|
"readiness": {
|
|
126
126
|
"type": "object",
|
|
127
127
|
"additionalProperties": false,
|
|
128
|
-
"required": ["ready", "blocked", "blockers"],
|
|
128
|
+
"required": ["ready", "blocked", "blockedBy", "terminal", "blockers"],
|
|
129
129
|
"properties": {
|
|
130
130
|
"ready": { "type": "boolean" },
|
|
131
131
|
"blocked": { "type": "boolean" },
|
|
132
|
+
"blockedBy": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": { "type": "string" }
|
|
135
|
+
},
|
|
136
|
+
"terminal": { "type": "boolean" },
|
|
132
137
|
"blockers": {
|
|
133
138
|
"type": "array",
|
|
134
139
|
"items": { "$ref": "#/$defs/blocker" }
|
package/src/cli-parser.test.ts
CHANGED
|
@@ -155,6 +155,22 @@ describe("strict CLI parsing", () => {
|
|
|
155
155
|
expectUsageError(["status", "--compact"], "agency status")
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
+
test("accepts work preparation options only for the prepare subcommand", () => {
|
|
159
|
+
expect(
|
|
160
|
+
parseCli(["work", "prepare", "tasks/example", "--dry-run", "--json"]),
|
|
161
|
+
).toMatchObject({
|
|
162
|
+
commandName: "work",
|
|
163
|
+
args: ["prepare", "tasks/example"],
|
|
164
|
+
values: { "dry-run": true, json: true },
|
|
165
|
+
})
|
|
166
|
+
expect(() => parseCli(["work", "example", "--dry-run"])).toThrow(
|
|
167
|
+
"only valid with 'agency work prepare'",
|
|
168
|
+
)
|
|
169
|
+
expect(() => parseCli(["work", "prepare", "--opencode"])).toThrow(
|
|
170
|
+
"cannot be combined",
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
158
174
|
test("accepts repeatable graph filters and rejects output conflicts", () => {
|
|
159
175
|
expect(
|
|
160
176
|
parseCli([
|
package/src/cli-parser.ts
CHANGED
|
@@ -283,18 +283,22 @@ const commands = {
|
|
|
283
283
|
},
|
|
284
284
|
},
|
|
285
285
|
work: {
|
|
286
|
-
usage:
|
|
286
|
+
usage:
|
|
287
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
287
288
|
options: {
|
|
288
289
|
...commonOptions,
|
|
290
|
+
json: { type: "boolean" },
|
|
291
|
+
"dry-run": { type: "boolean" },
|
|
289
292
|
epic: { type: "string" },
|
|
290
293
|
opencode: { type: "boolean" },
|
|
291
294
|
claude: { type: "boolean" },
|
|
292
295
|
},
|
|
293
296
|
command: {
|
|
294
|
-
usage:
|
|
297
|
+
usage:
|
|
298
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
295
299
|
minArgs: 0,
|
|
296
|
-
maxArgs:
|
|
297
|
-
options: ["epic", "opencode", "claude"],
|
|
300
|
+
maxArgs: 2,
|
|
301
|
+
options: ["json", "dry-run", "epic", "opencode", "claude"],
|
|
298
302
|
conflicts: [
|
|
299
303
|
["opencode", "claude"],
|
|
300
304
|
["epic", "$positional"],
|
|
@@ -649,6 +653,27 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
649
653
|
if (commandName === "graph") {
|
|
650
654
|
validateGraphOptions(parsed.values, spec)
|
|
651
655
|
}
|
|
656
|
+
if (commandName === "work") {
|
|
657
|
+
const preparing = commandPositionals[0] === "prepare"
|
|
658
|
+
if (
|
|
659
|
+
(!preparing && commandPositionals.length > 1) ||
|
|
660
|
+
(preparing &&
|
|
661
|
+
(parsed.values.epic || parsed.values.opencode || parsed.values.claude))
|
|
662
|
+
) {
|
|
663
|
+
throw usageError(
|
|
664
|
+
preparing
|
|
665
|
+
? "Work preparation cannot be combined with agent or epic options."
|
|
666
|
+
: "The work command accepts at most one target.",
|
|
667
|
+
spec.usage,
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
if (!preparing && (parsed.values.json || parsed.values["dry-run"])) {
|
|
671
|
+
throw usageError(
|
|
672
|
+
"Options '--json' and '--dry-run' are only valid with 'agency work prepare'.",
|
|
673
|
+
spec.usage,
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
652
677
|
|
|
653
678
|
return {
|
|
654
679
|
commandName: commandName as keyof typeof commands,
|
package/src/cli.test.ts
CHANGED
|
@@ -301,6 +301,76 @@ status: open
|
|
|
301
301
|
})
|
|
302
302
|
})
|
|
303
303
|
|
|
304
|
+
test("prepares a workspace and reports a non-mutating dry-run", async () => {
|
|
305
|
+
const parent = await createTempDir()
|
|
306
|
+
tempDirs.push(parent)
|
|
307
|
+
const root = join(parent, "workbase")
|
|
308
|
+
const source = join(parent, "source")
|
|
309
|
+
expect(
|
|
310
|
+
Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
|
|
311
|
+
).toBe(0)
|
|
312
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
313
|
+
for (const args of [
|
|
314
|
+
["config", "user.email", "test@example.com"],
|
|
315
|
+
["config", "user.name", "Test"],
|
|
316
|
+
["add", "README.md"],
|
|
317
|
+
["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
|
|
318
|
+
]) {
|
|
319
|
+
expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
parseJson(await runCli(["init", root, "--json"], parent))
|
|
323
|
+
parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
|
|
324
|
+
parseJson(
|
|
325
|
+
await runCli(
|
|
326
|
+
[
|
|
327
|
+
"task",
|
|
328
|
+
"create",
|
|
329
|
+
"example",
|
|
330
|
+
"--repo",
|
|
331
|
+
"agency",
|
|
332
|
+
"--branch",
|
|
333
|
+
"feat/example",
|
|
334
|
+
"--base",
|
|
335
|
+
"main",
|
|
336
|
+
"--json",
|
|
337
|
+
],
|
|
338
|
+
root,
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const planned = parseJson(
|
|
343
|
+
await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
|
|
344
|
+
)
|
|
345
|
+
const workbaseRoot = await realpath(root)
|
|
346
|
+
expect(planned).toMatchObject({
|
|
347
|
+
dryRun: true,
|
|
348
|
+
taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
|
|
349
|
+
phasePath: null,
|
|
350
|
+
checkouts: [
|
|
351
|
+
{
|
|
352
|
+
repo: "agency",
|
|
353
|
+
kind: "writable",
|
|
354
|
+
action: "created",
|
|
355
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
})
|
|
359
|
+
await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
|
|
360
|
+
|
|
361
|
+
const prepared = parseJson(
|
|
362
|
+
await runCli(["work", "prepare", "example", "--json"], root),
|
|
363
|
+
)
|
|
364
|
+
expect(prepared).toMatchObject({
|
|
365
|
+
dryRun: false,
|
|
366
|
+
checkouts: [{ action: "created", kind: "writable" }],
|
|
367
|
+
})
|
|
368
|
+
const task = parseJson(
|
|
369
|
+
await runCli(["task", "show", "example", "--json"], root),
|
|
370
|
+
)
|
|
371
|
+
expect(task.data.status).toBe("open")
|
|
372
|
+
})
|
|
373
|
+
|
|
304
374
|
test("envelopes help and version output in machine mode", async () => {
|
|
305
375
|
const help = await runCli(["status", "--help", "--json"])
|
|
306
376
|
expect(parseJson(help)).toContain("Usage: agency status")
|
|
@@ -7,7 +7,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
7
7
|
import { PhaseService } from "../services/PhaseService"
|
|
8
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
9
9
|
import { captureErrors, captureLogs } from "../test-utils"
|
|
10
|
-
import { work } from "./work"
|
|
10
|
+
import { work, workPrepare } from "./work"
|
|
11
11
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
12
12
|
import type { PickWorkbase } from "../workbase/workbase-choice"
|
|
13
13
|
import type { Progress } from "../utils/progress"
|
|
@@ -24,6 +24,9 @@ const singlePhaseWorkspace: ExecutionWorkspace = {
|
|
|
24
24
|
writablePath: "/workbase/tasks/example/code/agency",
|
|
25
25
|
repo: "agency",
|
|
26
26
|
repos: [],
|
|
27
|
+
dryRun: false,
|
|
28
|
+
checkouts: [],
|
|
29
|
+
operations: [],
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
@@ -34,6 +37,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
34
37
|
writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
|
|
35
38
|
repo: "agency",
|
|
36
39
|
repos: [],
|
|
40
|
+
dryRun: false,
|
|
41
|
+
checkouts: [],
|
|
42
|
+
operations: [],
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
interface HarnessOptions {
|
|
@@ -167,6 +173,16 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
173
|
Effect.provideService(PhaseService, phases as never),
|
|
168
174
|
) as Effect.Effect<void, unknown, never>,
|
|
169
175
|
)
|
|
176
|
+
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
177
|
+
Effect.runPromise(
|
|
178
|
+
workPrepare(commandOptions).pipe(
|
|
179
|
+
Effect.provideService(WorktreeService, worktrees as never),
|
|
180
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
181
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
182
|
+
Effect.provideService(TaskService, tasks as never),
|
|
183
|
+
Effect.provideService(PhaseService, phases as never),
|
|
184
|
+
) as Effect.Effect<void, unknown, never>,
|
|
185
|
+
)
|
|
170
186
|
|
|
171
187
|
return {
|
|
172
188
|
events,
|
|
@@ -177,10 +193,32 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
177
193
|
shownTasks,
|
|
178
194
|
progressUpdates,
|
|
179
195
|
run,
|
|
196
|
+
runPrepare,
|
|
180
197
|
}
|
|
181
198
|
}
|
|
182
199
|
|
|
183
200
|
describe("work command", () => {
|
|
201
|
+
test("prepares without launching or changing lifecycle status", async () => {
|
|
202
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
203
|
+
|
|
204
|
+
await captureLogs(() =>
|
|
205
|
+
harness.runPrepare({
|
|
206
|
+
cwd: "/workbase",
|
|
207
|
+
directory: "example",
|
|
208
|
+
json: true,
|
|
209
|
+
dryRun: true,
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
expect(harness.events).toEqual(["materialize"])
|
|
214
|
+
expect(harness.launches).toEqual([])
|
|
215
|
+
expect(harness.statusUpdates).toEqual([])
|
|
216
|
+
expect(harness.materializeOptions[0]).toMatchObject({
|
|
217
|
+
json: true,
|
|
218
|
+
dryRun: true,
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
184
222
|
test("launches an epic agent from an epic directory", async () => {
|
|
185
223
|
const harness = createHarness()
|
|
186
224
|
|
package/src/commands/work.ts
CHANGED
|
@@ -243,14 +243,74 @@ export const work = (
|
|
|
243
243
|
launch(cli, [cli, ...args], launchPath)
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
+
export const workPrepare = (options: WorkOptions = {}) =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const fs = yield* FileSystemService
|
|
249
|
+
const workbase = yield* WorkbaseService
|
|
250
|
+
const tasks = yield* TaskService
|
|
251
|
+
const phases = yield* PhaseService
|
|
252
|
+
const worktrees = yield* WorktreeService
|
|
253
|
+
const { log } = createLoggers(options)
|
|
254
|
+
const cwd = options.cwd ?? process.cwd()
|
|
255
|
+
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
256
|
+
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
257
|
+
const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
|
|
258
|
+
|
|
259
|
+
let taskId: string | undefined
|
|
260
|
+
let phaseId: string | undefined
|
|
261
|
+
if (options.directory && !isDirectory) {
|
|
262
|
+
const task = yield* tasks.show(options.directory, root)
|
|
263
|
+
taskId = task.id
|
|
264
|
+
} else {
|
|
265
|
+
const path = relative(root, targetPath)
|
|
266
|
+
const parts =
|
|
267
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
268
|
+
? []
|
|
269
|
+
: path.split(sep)
|
|
270
|
+
if (parts[0] === "tasks" && parts[1]) {
|
|
271
|
+
const task = yield* tasks.show(parts[1], root)
|
|
272
|
+
taskId = task.id
|
|
273
|
+
if (parts[2] === "phases" && parts[3]) {
|
|
274
|
+
const phase = yield* phases.show(task.id, parts[3], root)
|
|
275
|
+
phaseId = phase.id
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!taskId) {
|
|
281
|
+
return yield* Effect.fail(
|
|
282
|
+
new Error(
|
|
283
|
+
"Work preparation requires a task ID or a path inside an execution unit",
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
|
|
289
|
+
...options,
|
|
290
|
+
dryRun: options.dryRun,
|
|
291
|
+
})
|
|
292
|
+
if (options.json) {
|
|
293
|
+
log(JSON.stringify(workspace, null, 2))
|
|
294
|
+
} else {
|
|
295
|
+
log(
|
|
296
|
+
`${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath}`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
246
301
|
export const help = `
|
|
247
302
|
Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
303
|
+
agency work prepare [target] [--dry-run] [--json]
|
|
248
304
|
|
|
249
305
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
250
306
|
with fzf. A positional argument resolves as a directory first, then as a task
|
|
251
307
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
252
308
|
workbase first.
|
|
253
309
|
|
|
310
|
+
The prepare subcommand resolves and materializes an execution workspace without
|
|
311
|
+
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
312
|
+
changes without fetching, creating branches, or creating worktrees.
|
|
313
|
+
|
|
254
314
|
Options:
|
|
255
315
|
--epic <id> Work on an epic
|
|
256
316
|
--opencode Require OpenCode
|
package/src/graph-schema.test.ts
CHANGED
|
@@ -14,6 +14,13 @@ describe("graph contract", () => {
|
|
|
14
14
|
required: ["ready", "blocked", "statuses", "repositories", "kinds"],
|
|
15
15
|
})
|
|
16
16
|
expect(jsonSchema.$defs.node.allOf).toHaveLength(5)
|
|
17
|
+
expect(jsonSchema.$defs.readiness.required).toEqual([
|
|
18
|
+
"ready",
|
|
19
|
+
"blocked",
|
|
20
|
+
"blockedBy",
|
|
21
|
+
"terminal",
|
|
22
|
+
"blockers",
|
|
23
|
+
])
|
|
17
24
|
expect(jsonSchema.$defs.node.allOf[3]?.then?.properties).toMatchObject({
|
|
18
25
|
status: { type: "null" },
|
|
19
26
|
readiness: { type: "null" },
|
package/src/graph-schema.ts
CHANGED
|
@@ -46,6 +46,8 @@ export const GraphProgress = Schema.Struct({
|
|
|
46
46
|
export const GraphReadiness = Schema.Struct({
|
|
47
47
|
ready: Schema.Boolean,
|
|
48
48
|
blocked: Schema.Boolean,
|
|
49
|
+
blockedBy: Schema.Array(Schema.String),
|
|
50
|
+
terminal: Schema.Boolean,
|
|
49
51
|
blockers: Schema.Array(GraphBlocker),
|
|
50
52
|
})
|
|
51
53
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
aggregateProgress,
|
|
4
|
+
canTransitionStatus,
|
|
5
|
+
isDependencySatisfied,
|
|
6
|
+
readinessState,
|
|
7
|
+
} from "./readiness"
|
|
8
|
+
|
|
9
|
+
describe("readiness model", () => {
|
|
10
|
+
test("only done satisfies dependencies and terminal states remain distinct", () => {
|
|
11
|
+
expect(isDependencySatisfied("done")).toBe(true)
|
|
12
|
+
expect(isDependencySatisfied("dropped")).toBe(false)
|
|
13
|
+
expect(readinessState("done", [{ id: "task:one" }])).toEqual({
|
|
14
|
+
ready: false,
|
|
15
|
+
blocked: true,
|
|
16
|
+
blockedBy: ["task:one"],
|
|
17
|
+
terminal: true,
|
|
18
|
+
})
|
|
19
|
+
expect(readinessState("working", [{ id: "claim:self" }])).toEqual({
|
|
20
|
+
ready: false,
|
|
21
|
+
blocked: true,
|
|
22
|
+
blockedBy: ["claim:self"],
|
|
23
|
+
terminal: false,
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("rolls child statuses into deterministic aggregate progress", () => {
|
|
28
|
+
expect(aggregateProgress(["done", "dropped"])).toEqual({
|
|
29
|
+
status: "dropped",
|
|
30
|
+
total: 2,
|
|
31
|
+
open: 0,
|
|
32
|
+
working: 0,
|
|
33
|
+
delegated: 0,
|
|
34
|
+
done: 1,
|
|
35
|
+
dropped: 1,
|
|
36
|
+
terminal: 2,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("requires terminal work to reopen before changing its outcome", () => {
|
|
41
|
+
expect(canTransitionStatus("open", "done")).toBe(true)
|
|
42
|
+
expect(canTransitionStatus("working", "delegated")).toBe(true)
|
|
43
|
+
expect(canTransitionStatus("done", "dropped")).toBe(false)
|
|
44
|
+
expect(canTransitionStatus("dropped", "done")).toBe(false)
|
|
45
|
+
expect(canTransitionStatus("done", "open")).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WorkStatus } from "./workbase/schemas"
|
|
2
|
+
|
|
3
|
+
export interface ReadinessBlocker {
|
|
4
|
+
readonly id: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const WORK_STATUS_TRANSITIONS = {
|
|
8
|
+
open: ["open", "working", "delegated", "done", "dropped"],
|
|
9
|
+
working: ["open", "working", "delegated", "done", "dropped"],
|
|
10
|
+
delegated: ["open", "working", "delegated", "done", "dropped"],
|
|
11
|
+
done: ["open", "done"],
|
|
12
|
+
dropped: ["open", "dropped"],
|
|
13
|
+
} as const satisfies Record<WorkStatus, readonly WorkStatus[]>
|
|
14
|
+
|
|
15
|
+
export const isTerminalStatus = (status: WorkStatus) =>
|
|
16
|
+
status === "done" || status === "dropped"
|
|
17
|
+
|
|
18
|
+
export const isDependencySatisfied = (status: WorkStatus | undefined) =>
|
|
19
|
+
status === "done"
|
|
20
|
+
|
|
21
|
+
export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
|
|
22
|
+
(WORK_STATUS_TRANSITIONS[from] as readonly WorkStatus[]).includes(to)
|
|
23
|
+
|
|
24
|
+
export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
|
|
25
|
+
const counts = {
|
|
26
|
+
total: statuses.length,
|
|
27
|
+
open: statuses.filter((status) => status === "open").length,
|
|
28
|
+
working: statuses.filter((status) => status === "working").length,
|
|
29
|
+
delegated: statuses.filter((status) => status === "delegated").length,
|
|
30
|
+
done: statuses.filter((status) => status === "done").length,
|
|
31
|
+
dropped: statuses.filter((status) => status === "dropped").length,
|
|
32
|
+
terminal: statuses.filter(isTerminalStatus).length,
|
|
33
|
+
}
|
|
34
|
+
const status: WorkStatus =
|
|
35
|
+
statuses.length === 0
|
|
36
|
+
? "open"
|
|
37
|
+
: statuses.every((value) => value === "done")
|
|
38
|
+
? "done"
|
|
39
|
+
: statuses.every(isTerminalStatus)
|
|
40
|
+
? "dropped"
|
|
41
|
+
: statuses.includes("working")
|
|
42
|
+
? "working"
|
|
43
|
+
: statuses.includes("delegated")
|
|
44
|
+
? "delegated"
|
|
45
|
+
: "open"
|
|
46
|
+
return { status, ...counts }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const readinessState = (
|
|
50
|
+
status: WorkStatus,
|
|
51
|
+
blockers: readonly ReadinessBlocker[],
|
|
52
|
+
ready = status === "open" && blockers.length === 0,
|
|
53
|
+
) => ({
|
|
54
|
+
ready,
|
|
55
|
+
blocked: !ready && blockers.length > 0,
|
|
56
|
+
blockedBy: [...new Set(blockers.map((blocker) => blocker.id))].sort(),
|
|
57
|
+
terminal: isTerminalStatus(status),
|
|
58
|
+
})
|
|
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from "node:path"
|
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
7
|
+
import { aggregateProgress, readinessState } from "../readiness"
|
|
7
8
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
8
9
|
import {
|
|
9
10
|
EpicFrontmatter,
|
|
@@ -63,29 +64,6 @@ interface ReferenceCheckout extends CheckoutInspection {
|
|
|
63
64
|
readonly resolvedCommit: string | null
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const statusCounts = (statuses: readonly WorkStatus[]) => ({
|
|
67
|
-
total: statuses.length,
|
|
68
|
-
open: statuses.filter((status) => status === "open").length,
|
|
69
|
-
working: statuses.filter((status) => status === "working").length,
|
|
70
|
-
delegated: statuses.filter((status) => status === "delegated").length,
|
|
71
|
-
done: statuses.filter((status) => status === "done").length,
|
|
72
|
-
dropped: statuses.filter((status) => status === "dropped").length,
|
|
73
|
-
terminal: statuses.filter(
|
|
74
|
-
(status) => status === "done" || status === "dropped",
|
|
75
|
-
).length,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const aggregateStatus = (statuses: readonly WorkStatus[]): WorkStatus => {
|
|
79
|
-
if (statuses.length === 0) return "open"
|
|
80
|
-
if (statuses.every((status) => status === "done")) return "done"
|
|
81
|
-
if (statuses.every((status) => status === "done" || status === "dropped")) {
|
|
82
|
-
return "dropped"
|
|
83
|
-
}
|
|
84
|
-
if (statuses.includes("working")) return "working"
|
|
85
|
-
if (statuses.includes("delegated")) return "delegated"
|
|
86
|
-
return "open"
|
|
87
|
-
}
|
|
88
|
-
|
|
89
67
|
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
90
68
|
schema: S,
|
|
91
69
|
input: unknown,
|
|
@@ -337,13 +315,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
337
315
|
return (record.data as PhaseData).status
|
|
338
316
|
const data = record.data as TaskData
|
|
339
317
|
if (!("phases" in data)) return data.status
|
|
340
|
-
return
|
|
318
|
+
return aggregateProgress(
|
|
341
319
|
data.phases.map(
|
|
342
320
|
(item) =>
|
|
343
321
|
phaseDocuments.get(`${record.taskId}/${item.id}`)?.data
|
|
344
322
|
.status ?? "open",
|
|
345
323
|
),
|
|
346
|
-
)
|
|
324
|
+
).status
|
|
347
325
|
}
|
|
348
326
|
|
|
349
327
|
const taskDependencyEntries = (
|
|
@@ -551,7 +529,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
551
529
|
)
|
|
552
530
|
: [child.data.status]
|
|
553
531
|
})
|
|
554
|
-
targetStatus =
|
|
532
|
+
targetStatus = aggregateProgress(aggregateStatuses).status
|
|
555
533
|
descendantsReady = epic.data.tasks.some((item: Dependency) =>
|
|
556
534
|
taskReady(item.id),
|
|
557
535
|
)
|
|
@@ -832,14 +810,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
832
810
|
dependencies,
|
|
833
811
|
dependents,
|
|
834
812
|
readiness: {
|
|
835
|
-
ready,
|
|
836
|
-
blocked: !ready && blockers.length > 0,
|
|
813
|
+
...readinessState(targetStatus, blockers, ready),
|
|
837
814
|
blockers,
|
|
838
815
|
},
|
|
839
|
-
aggregate:
|
|
840
|
-
status: aggregateStatus(aggregateStatuses),
|
|
841
|
-
...statusCounts(aggregateStatuses),
|
|
842
|
-
},
|
|
816
|
+
aggregate: aggregateProgress(aggregateStatuses),
|
|
843
817
|
},
|
|
844
818
|
authority: {
|
|
845
819
|
mode: executionData ? "execution" : "orchestration",
|