@markjaquith/agency 2.3.0 → 2.5.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 +45 -16
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +31 -4
- package/src/cli.test.ts +1 -0
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/pr.ts +1 -0
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/work.test.ts +63 -34
- package/src/commands/work.ts +20 -3
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/FileSystemService.ts +7 -2
- package/src/services/PhaseService.ts +28 -0
- package/src/services/PullRequestService.ts +3 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +66 -0
- package/src/services/WorkbaseService.ts +32 -2
- package/src/services/WorktreeService.test.ts +210 -2
- package/src/services/WorktreeService.ts +143 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/process.test.ts +38 -1
- package/src/utils/process.ts +30 -6
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +50 -0
- package/src/workbase/schemas.ts +6 -2
- package/src/workbase/work-target.test.ts +9 -9
- package/src/workbase/work-target.ts +36 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Data, Effect } from "effect"
|
|
2
|
-
import { dirname, join, resolve } from "node:path"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
3
|
import { FileSystemService } from "./FileSystemService"
|
|
4
4
|
import { WorkbaseService } from "./WorkbaseService"
|
|
5
5
|
import { TaskService } from "./TaskService"
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
worktreeCommandEnvironment,
|
|
10
10
|
} from "../workbase/worktree-command"
|
|
11
11
|
import type { RepositoryReference } from "../workbase/schemas"
|
|
12
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
13
|
+
import { createLoggers } from "../utils/effect"
|
|
12
14
|
|
|
13
15
|
class WorktreeError extends Data.TaggedError("WorktreeError")<{
|
|
14
16
|
readonly message: string
|
|
@@ -49,6 +51,15 @@ const parseWorktreeList = (output: string): readonly GitWorktree[] => {
|
|
|
49
51
|
return worktrees
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
const formatCommand = (args: readonly string[]) =>
|
|
55
|
+
args
|
|
56
|
+
.map((argument) =>
|
|
57
|
+
/^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
|
|
58
|
+
? argument
|
|
59
|
+
: `'${argument.replaceAll("'", `'\\''`)}'`,
|
|
60
|
+
)
|
|
61
|
+
.join(" ")
|
|
62
|
+
|
|
52
63
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
53
64
|
"WorktreeService",
|
|
54
65
|
{
|
|
@@ -57,12 +68,16 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
57
68
|
taskId: string,
|
|
58
69
|
phaseId?: string,
|
|
59
70
|
startPath: string = process.cwd(),
|
|
71
|
+
options: BaseCommandOptions = {},
|
|
60
72
|
) =>
|
|
61
73
|
Effect.gen(function* () {
|
|
62
74
|
const fs = yield* FileSystemService
|
|
63
75
|
const workbase = yield* WorkbaseService
|
|
64
76
|
const tasks = yield* TaskService
|
|
65
77
|
const phases = yield* PhaseService
|
|
78
|
+
const { verboseLog } = createLoggers(options)
|
|
79
|
+
const forwardCommandOutput =
|
|
80
|
+
options.verbose === true && !options.silent && !options.json
|
|
66
81
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
67
82
|
const report = yield* workbase.validate(root)
|
|
68
83
|
const ownershipIssue = report.issues.find((issue) =>
|
|
@@ -258,9 +273,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
258
273
|
]
|
|
259
274
|
}
|
|
260
275
|
|
|
276
|
+
if (config.worktreeCreateCommand) {
|
|
277
|
+
verboseLog(`Running worktree command: ${formatCommand(args)}`)
|
|
278
|
+
}
|
|
261
279
|
const result = yield* fs.runCommand(args, {
|
|
262
280
|
cwd: repositoryPath,
|
|
263
281
|
captureOutput: true,
|
|
282
|
+
forwardOutput:
|
|
283
|
+
config.worktreeCreateCommand && forwardCommandOutput,
|
|
264
284
|
env,
|
|
265
285
|
})
|
|
266
286
|
if (result.exitCode !== 0) {
|
|
@@ -352,6 +372,128 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
352
372
|
repos: execution.repos ?? [],
|
|
353
373
|
} satisfies ExecutionWorkspace
|
|
354
374
|
}),
|
|
375
|
+
|
|
376
|
+
remove: (
|
|
377
|
+
taskId: string,
|
|
378
|
+
phaseId?: string,
|
|
379
|
+
startPath: string = process.cwd(),
|
|
380
|
+
) =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const fs = yield* FileSystemService
|
|
383
|
+
const workbase = yield* WorkbaseService
|
|
384
|
+
const tasks = yield* TaskService
|
|
385
|
+
const phases = yield* PhaseService
|
|
386
|
+
const root = yield* workbase.discover(startPath)
|
|
387
|
+
const task = yield* tasks.show(taskId, root)
|
|
388
|
+
|
|
389
|
+
let execution: {
|
|
390
|
+
repo: string
|
|
391
|
+
repos?: readonly RepositoryReference[]
|
|
392
|
+
}
|
|
393
|
+
let codePath: string
|
|
394
|
+
if ("phases" in task.data) {
|
|
395
|
+
if (!phaseId) {
|
|
396
|
+
return yield* new WorktreeError({
|
|
397
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
const phase = yield* phases.show(taskId, phaseId, root)
|
|
401
|
+
execution = phase.data
|
|
402
|
+
codePath = join(dirname(phase.path), "code")
|
|
403
|
+
} else {
|
|
404
|
+
if (phaseId) {
|
|
405
|
+
return yield* new WorktreeError({
|
|
406
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
execution = task.data
|
|
410
|
+
codePath = join(dirname(task.path), "code")
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const codeDirectoryExists = yield* fs.isDirectory(codePath)
|
|
414
|
+
const removed: string[] = []
|
|
415
|
+
for (const alias of [
|
|
416
|
+
execution.repo,
|
|
417
|
+
...(execution.repos ?? []).map((reference) => reference.repo),
|
|
418
|
+
]) {
|
|
419
|
+
const repositoryPath = join(root, "repos", alias)
|
|
420
|
+
const checkoutPath = join(codePath, alias)
|
|
421
|
+
const listed = yield* fs.runCommand(
|
|
422
|
+
[
|
|
423
|
+
"git",
|
|
424
|
+
"-C",
|
|
425
|
+
repositoryPath,
|
|
426
|
+
"worktree",
|
|
427
|
+
"list",
|
|
428
|
+
"--porcelain",
|
|
429
|
+
"-z",
|
|
430
|
+
],
|
|
431
|
+
{ captureOutput: true },
|
|
432
|
+
)
|
|
433
|
+
if (listed.exitCode !== 0) {
|
|
434
|
+
return yield* new WorktreeError({
|
|
435
|
+
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const checkoutExists = yield* fs.isDirectory(checkoutPath)
|
|
440
|
+
const canonicalCheckoutPath = checkoutExists
|
|
441
|
+
? yield* fs.realPath(checkoutPath)
|
|
442
|
+
: join(
|
|
443
|
+
yield* fs.realPath(dirname(codePath)),
|
|
444
|
+
basename(codePath),
|
|
445
|
+
alias,
|
|
446
|
+
)
|
|
447
|
+
let registeredPath: string | undefined
|
|
448
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
449
|
+
const worktreePath = (yield* fs.exists(worktree.path))
|
|
450
|
+
? yield* fs.realPath(worktree.path)
|
|
451
|
+
: resolve(worktree.path)
|
|
452
|
+
if (worktreePath === canonicalCheckoutPath) {
|
|
453
|
+
registeredPath = worktreePath
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (!registeredPath) {
|
|
458
|
+
if (checkoutExists) {
|
|
459
|
+
return yield* new WorktreeError({
|
|
460
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const result = yield* fs.runCommand(
|
|
467
|
+
[
|
|
468
|
+
"git",
|
|
469
|
+
"-C",
|
|
470
|
+
repositoryPath,
|
|
471
|
+
"worktree",
|
|
472
|
+
"remove",
|
|
473
|
+
...(!checkoutExists ? ["--force"] : []),
|
|
474
|
+
checkoutExists ? checkoutPath : registeredPath,
|
|
475
|
+
],
|
|
476
|
+
{ captureOutput: true },
|
|
477
|
+
)
|
|
478
|
+
if (result.exitCode !== 0) {
|
|
479
|
+
return yield* new WorktreeError({
|
|
480
|
+
message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
if (checkoutExists) removed.push(checkoutPath)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
|
|
487
|
+
const remaining = yield* fs.readDirectory(codePath)
|
|
488
|
+
if (remaining.length > 0) {
|
|
489
|
+
return yield* new WorktreeError({
|
|
490
|
+
message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
yield* fs.deleteDirectory(codePath)
|
|
494
|
+
}
|
|
495
|
+
return removed
|
|
496
|
+
}),
|
|
355
497
|
}),
|
|
356
498
|
},
|
|
357
499
|
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
|
|
|
11
11
|
import { PhaseService } from "./services/PhaseService"
|
|
12
12
|
import { WorktreeService } from "./services/WorktreeService"
|
|
13
13
|
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
+
import { ArchiveService } from "./services/ArchiveService"
|
|
14
15
|
|
|
15
16
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
16
17
|
|
|
@@ -26,6 +27,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
26
27
|
PhaseService.Default,
|
|
27
28
|
WorktreeService.Default,
|
|
28
29
|
PullRequestService.Default,
|
|
30
|
+
ArchiveService.Default,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
export async function runTestEffect<A, E>(
|
|
@@ -1,8 +1,45 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { spawnProcess } from "./process"
|
|
4
4
|
|
|
5
5
|
describe("spawnProcess", () => {
|
|
6
|
+
test("forwards and captures output in tee mode", async () => {
|
|
7
|
+
const forwardedStdout: Uint8Array[] = []
|
|
8
|
+
const forwardedStderr: Uint8Array[] = []
|
|
9
|
+
const stdout = spyOn(process.stdout, "write").mockImplementation(((
|
|
10
|
+
chunk: Uint8Array,
|
|
11
|
+
) => {
|
|
12
|
+
forwardedStdout.push(chunk)
|
|
13
|
+
return true
|
|
14
|
+
}) as never)
|
|
15
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(((
|
|
16
|
+
chunk: Uint8Array,
|
|
17
|
+
) => {
|
|
18
|
+
forwardedStderr.push(chunk)
|
|
19
|
+
return true
|
|
20
|
+
}) as never)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await Effect.runPromise(
|
|
24
|
+
spawnProcess(
|
|
25
|
+
["sh", "-c", "printf 'standard output'; printf 'standard error' >&2"],
|
|
26
|
+
{ stdout: "tee", stderr: "tee" },
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({
|
|
31
|
+
stdout: "standard output",
|
|
32
|
+
stderr: "standard error",
|
|
33
|
+
exitCode: 0,
|
|
34
|
+
})
|
|
35
|
+
expect(Buffer.concat(forwardedStdout).toString()).toBe("standard output")
|
|
36
|
+
expect(Buffer.concat(forwardedStderr).toString()).toBe("standard error")
|
|
37
|
+
} finally {
|
|
38
|
+
stdout.mockRestore()
|
|
39
|
+
stderr.mockRestore()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
6
43
|
test("captures large stdout and stderr without hanging", async () => {
|
|
7
44
|
const line = "x".repeat(4096)
|
|
8
45
|
const script = [
|
package/src/utils/process.ts
CHANGED
|
@@ -15,11 +15,29 @@ interface ProcessResult {
|
|
|
15
15
|
interface SpawnOptions {
|
|
16
16
|
readonly cwd?: string
|
|
17
17
|
readonly stdin?: "pipe" | "inherit"
|
|
18
|
-
readonly stdout?: "pipe" | "inherit"
|
|
19
|
-
readonly stderr?: "pipe" | "inherit"
|
|
18
|
+
readonly stdout?: "pipe" | "inherit" | "tee"
|
|
19
|
+
readonly stderr?: "pipe" | "inherit" | "tee"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
const readOutput = async (
|
|
24
|
+
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
25
|
+
target?: { write(chunk: Uint8Array): unknown },
|
|
26
|
+
) => {
|
|
27
|
+
if (!stream) return ""
|
|
28
|
+
|
|
29
|
+
const reader = stream.getReader()
|
|
30
|
+
const decoder = new TextDecoder()
|
|
31
|
+
let output = ""
|
|
32
|
+
while (true) {
|
|
33
|
+
const { done, value } = await reader.read()
|
|
34
|
+
if (done) break
|
|
35
|
+
target?.write(value)
|
|
36
|
+
output += decoder.decode(value, { stream: true })
|
|
37
|
+
}
|
|
38
|
+
return output + decoder.decode()
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
/**
|
|
24
42
|
* Generic error for process execution failures
|
|
25
43
|
*/
|
|
@@ -50,8 +68,8 @@ export const spawnProcess = (
|
|
|
50
68
|
const proc = Bun.spawn([...args], {
|
|
51
69
|
cwd: options?.cwd ?? process.cwd(),
|
|
52
70
|
stdin: options?.stdin ?? "pipe",
|
|
53
|
-
stdout: options?.stdout
|
|
54
|
-
stderr: options?.stderr
|
|
71
|
+
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
72
|
+
stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
|
|
55
73
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
56
74
|
})
|
|
57
75
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
@@ -59,11 +77,17 @@ export const spawnProcess = (
|
|
|
59
77
|
const stdoutPromise =
|
|
60
78
|
options?.stdout === "inherit"
|
|
61
79
|
? Promise.resolve("")
|
|
62
|
-
:
|
|
80
|
+
: readOutput(
|
|
81
|
+
proc.stdout,
|
|
82
|
+
options?.stdout === "tee" ? process.stdout : undefined,
|
|
83
|
+
)
|
|
63
84
|
const stderrPromise =
|
|
64
85
|
options?.stderr === "inherit"
|
|
65
86
|
? Promise.resolve("")
|
|
66
|
-
:
|
|
87
|
+
: readOutput(
|
|
88
|
+
proc.stderr,
|
|
89
|
+
options?.stderr === "tee" ? process.stderr : undefined,
|
|
90
|
+
)
|
|
67
91
|
|
|
68
92
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
69
93
|
proc.exited,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createProgress } from "./progress"
|
|
3
|
+
|
|
4
|
+
describe("progress", () => {
|
|
5
|
+
test("replaces an active TTY line with its completed state", () => {
|
|
6
|
+
const output: string[] = []
|
|
7
|
+
const progress = createProgress(
|
|
8
|
+
{},
|
|
9
|
+
{ isTTY: true, write: (text) => output.push(text) },
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
progress.start("Preparing workspace...")
|
|
13
|
+
progress.succeed("Workspace ready")
|
|
14
|
+
|
|
15
|
+
expect(output).toEqual([
|
|
16
|
+
"\r\x1b[2K\x1b[2m○\x1b[0m Preparing workspace...",
|
|
17
|
+
"\r\x1b[2K\x1b[32m✓\x1b[0m Workspace ready\n",
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("stays quiet for silent or non-TTY output", () => {
|
|
22
|
+
const output: string[] = []
|
|
23
|
+
for (const [silent, isTTY] of [
|
|
24
|
+
[true, true],
|
|
25
|
+
[false, false],
|
|
26
|
+
] as const) {
|
|
27
|
+
const progress = createProgress(
|
|
28
|
+
{ silent },
|
|
29
|
+
{ isTTY, write: (text) => output.push(text) },
|
|
30
|
+
)
|
|
31
|
+
progress.start("Preparing workspace...")
|
|
32
|
+
progress.fail("Workspace preparation failed")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
expect(output).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface ProgressOptions {
|
|
2
|
+
readonly silent?: boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface ProgressOutput {
|
|
6
|
+
readonly isTTY: boolean
|
|
7
|
+
readonly write: (text: string) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Progress {
|
|
11
|
+
readonly start: (message: string) => void
|
|
12
|
+
readonly succeed: (message: string) => void
|
|
13
|
+
readonly fail: (message: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const terminalOutput: ProgressOutput = {
|
|
17
|
+
isTTY: Boolean(process.stderr.isTTY),
|
|
18
|
+
write: (text) => process.stderr.write(text),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createProgress = (
|
|
22
|
+
options: ProgressOptions,
|
|
23
|
+
output: ProgressOutput = terminalOutput,
|
|
24
|
+
): Progress => {
|
|
25
|
+
const enabled = !options.silent && output.isTTY
|
|
26
|
+
const write = (symbol: string, message: string, complete: boolean) => {
|
|
27
|
+
if (!enabled) return
|
|
28
|
+
output.write(`\r\x1b[2K${symbol} ${message}${complete ? "\n" : ""}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
start: (message) => write("\x1b[2m○\x1b[0m", message, false),
|
|
33
|
+
succeed: (message) => write("\x1b[32m✓\x1b[0m", message, true),
|
|
34
|
+
fail: (message) => write("\x1b[31m✗\x1b[0m", message, true),
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -24,7 +24,10 @@ field. Repositories listed in plural `repos` are read-only references.
|
|
|
24
24
|
|
|
25
25
|
- Keep task-level decisions in `TASK.md` and phase-specific delivery context in
|
|
26
26
|
`PHASE.md`.
|
|
27
|
+
- Keep execution-unit `status` current with `agency task status` or
|
|
28
|
+
`agency phase status`; `agency work` marks launched work as `working`.
|
|
27
29
|
- Do not manually create, move, or remove worktrees under `code/`.
|
|
30
|
+
- Use `agency archive`, rather than moving work item folders manually.
|
|
28
31
|
- Do not edit bare repositories or repository symlinks under `repos/`.
|
|
29
32
|
- Do not run `agency work` from an active agent session unless the user
|
|
30
33
|
explicitly asks to launch another agent.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
const managedHeaderPattern =
|
|
4
|
+
/^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
|
|
5
|
+
|
|
6
|
+
const checksum = (content: string) =>
|
|
7
|
+
createHash("sha256").update(content).digest("hex")
|
|
8
|
+
|
|
9
|
+
const body = `${JSON.stringify(
|
|
10
|
+
{
|
|
11
|
+
$schema: "https://opencode.ai/config.json",
|
|
12
|
+
references: {
|
|
13
|
+
tasks: {
|
|
14
|
+
path: "../tasks",
|
|
15
|
+
description: "Agency task definitions and execution context",
|
|
16
|
+
},
|
|
17
|
+
epics: {
|
|
18
|
+
path: "../epics",
|
|
19
|
+
description: "Agency epic definitions and orchestration context",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
null,
|
|
24
|
+
2,
|
|
25
|
+
)}\n`
|
|
26
|
+
|
|
27
|
+
const renderManagedWorkbaseOpencode = (content: string = body) =>
|
|
28
|
+
`// agency-managed: sha256=${checksum(content)}\n\n${content}`
|
|
29
|
+
|
|
30
|
+
export const managedWorkbaseOpencode = renderManagedWorkbaseOpencode()
|
|
31
|
+
|
|
32
|
+
export const canUpdateManagedWorkbaseOpencode = (content: string) => {
|
|
33
|
+
const match = content.match(managedHeaderPattern)
|
|
34
|
+
if (!match?.[1]) return false
|
|
35
|
+
|
|
36
|
+
return checksum(content.slice(match[0].length)) === match[1]
|
|
37
|
+
}
|
|
@@ -53,6 +53,15 @@ describe("body-of-work descriptions", () => {
|
|
|
53
53
|
expect(epic.description).toBeUndefined()
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
+
test("allows tasks without an external ticket", () => {
|
|
57
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
58
|
+
ticketUrl: null,
|
|
59
|
+
phases: [],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(task.ticketUrl).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
56
65
|
test("rejects an empty description when present", () => {
|
|
57
66
|
expect(() =>
|
|
58
67
|
Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
@@ -66,6 +75,47 @@ describe("body-of-work descriptions", () => {
|
|
|
66
75
|
})
|
|
67
76
|
})
|
|
68
77
|
|
|
78
|
+
describe("work status", () => {
|
|
79
|
+
test("defaults execution units to open", () => {
|
|
80
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
81
|
+
ticketUrl: "https://example.com/task",
|
|
82
|
+
repo: "agency",
|
|
83
|
+
branch: "task/default-status",
|
|
84
|
+
base: "main",
|
|
85
|
+
pr: null,
|
|
86
|
+
})
|
|
87
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
88
|
+
repo: "agency",
|
|
89
|
+
branch: "task/default-phase-status",
|
|
90
|
+
base: "main",
|
|
91
|
+
pr: null,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect("status" in task && task.status).toBe("open")
|
|
95
|
+
expect(phase.status).toBe("open")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("accepts supported statuses and rejects other values", () => {
|
|
99
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
100
|
+
repo: "agency",
|
|
101
|
+
branch: "task/done",
|
|
102
|
+
base: "main",
|
|
103
|
+
pr: null,
|
|
104
|
+
status: "done",
|
|
105
|
+
})
|
|
106
|
+
expect(phase.status).toBe("done")
|
|
107
|
+
expect(() =>
|
|
108
|
+
Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
109
|
+
repo: "agency",
|
|
110
|
+
branch: "task/invalid",
|
|
111
|
+
base: "main",
|
|
112
|
+
pr: null,
|
|
113
|
+
status: "blocked",
|
|
114
|
+
}),
|
|
115
|
+
).toThrow()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
69
119
|
describe("schema boundaries", () => {
|
|
70
120
|
const rejects = <S extends Schema.Schema.AnyNoContext>(
|
|
71
121
|
schema: S,
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const RepositoryReference = Schema.Struct({
|
|
|
15
15
|
ref: NonEmptyString,
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
export const WorkStatus = Schema.Literal("open", "working", "done", "dropped")
|
|
19
|
+
|
|
18
20
|
const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
|
|
19
21
|
|
|
20
22
|
const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
@@ -37,6 +39,7 @@ const ExecutionUnit = {
|
|
|
37
39
|
branch: NonEmptyString,
|
|
38
40
|
base: NonEmptyString,
|
|
39
41
|
pr: Schema.NullOr(GitHubPullRequestUrl),
|
|
42
|
+
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export const EpicFrontmatter = Schema.Struct({
|
|
@@ -47,14 +50,14 @@ export const EpicFrontmatter = Schema.Struct({
|
|
|
47
50
|
})
|
|
48
51
|
|
|
49
52
|
const SinglePhaseTaskFrontmatter = Schema.Struct({
|
|
50
|
-
ticketUrl: Url,
|
|
53
|
+
ticketUrl: Schema.NullOr(Url),
|
|
51
54
|
description: Description,
|
|
52
55
|
epic: Schema.optional(EntityId),
|
|
53
56
|
...ExecutionUnit,
|
|
54
57
|
})
|
|
55
58
|
|
|
56
59
|
const MultiPhaseTaskFrontmatter = Schema.Struct({
|
|
57
|
-
ticketUrl: Url,
|
|
60
|
+
ticketUrl: Schema.NullOr(Url),
|
|
58
61
|
description: Description,
|
|
59
62
|
epic: Schema.optional(EntityId),
|
|
60
63
|
phases: Schema.Array(Dependency),
|
|
@@ -73,6 +76,7 @@ export const PhaseFrontmatter = Schema.Struct({
|
|
|
73
76
|
export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
|
|
74
77
|
export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
75
78
|
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
79
|
+
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
76
80
|
export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
|
|
77
81
|
export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
|
|
78
82
|
export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
|
|
@@ -25,7 +25,7 @@ describe("work target choices", () => {
|
|
|
25
25
|
{
|
|
26
26
|
id: "single",
|
|
27
27
|
path: "/workbase/tasks/single/TASK.md",
|
|
28
|
-
data: {},
|
|
28
|
+
data: { status: "done" },
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
id: "standalone",
|
|
@@ -38,19 +38,19 @@ describe("work target choices", () => {
|
|
|
38
38
|
taskId: "multi",
|
|
39
39
|
id: "verify",
|
|
40
40
|
path: "/workbase/tasks/multi/phases/verify/PHASE.md",
|
|
41
|
-
data: {},
|
|
41
|
+
data: { status: "done" },
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
taskId: "multi",
|
|
45
45
|
id: "build",
|
|
46
46
|
path: "/workbase/tasks/multi/phases/build/PHASE.md",
|
|
47
|
-
data: {},
|
|
47
|
+
data: { status: "working" },
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
taskId: "multi",
|
|
51
51
|
id: "unlisted",
|
|
52
52
|
path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
|
|
53
|
-
data: {},
|
|
53
|
+
data: { status: "dropped" },
|
|
54
54
|
},
|
|
55
55
|
],
|
|
56
56
|
)
|
|
@@ -58,11 +58,11 @@ describe("work target choices", () => {
|
|
|
58
58
|
expect(choices.map((choice) => choice.label)).toEqual([
|
|
59
59
|
"\x1b[35m\x1b[0m delivery\x1b[2m - Ship the release\x1b[0m",
|
|
60
60
|
" \x1b[36m\x1b[0m multi",
|
|
61
|
-
" \x1b[33m\x1b[0m build",
|
|
62
|
-
" \x1b[33m\x1b[0m verify",
|
|
63
|
-
" \x1b[33m\x1b[0m unlisted",
|
|
64
|
-
" \x1b[36m\x1b[0m single",
|
|
65
|
-
"\x1b[36m\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
|
|
61
|
+
" \x1b[34m◐\x1b[0m \x1b[33m\x1b[0m build",
|
|
62
|
+
" \x1b[32m✓\x1b[0m \x1b[33m\x1b[0m verify",
|
|
63
|
+
" \x1b[31m⊘\x1b[0m \x1b[33m\x1b[0m unlisted",
|
|
64
|
+
" \x1b[32m✓\x1b[0m \x1b[36m\x1b[0m single",
|
|
65
|
+
"\x1b[2m○\x1b[0m \x1b[36m\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
|
|
66
66
|
])
|
|
67
67
|
expect(choices.map((choice) => choice.target.kind)).toEqual([
|
|
68
68
|
"epic",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import type { WorkStatus } from "./schemas"
|
|
2
3
|
|
|
3
4
|
export type WorkTarget =
|
|
4
5
|
| {
|
|
@@ -36,14 +37,17 @@ interface TaskRecord {
|
|
|
36
37
|
readonly description?: string
|
|
37
38
|
readonly phases: readonly { readonly id: string }[]
|
|
38
39
|
}
|
|
39
|
-
| { readonly description?: string }
|
|
40
|
+
| { readonly description?: string; readonly status?: WorkStatus }
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
interface PhaseRecord {
|
|
43
44
|
readonly taskId: string
|
|
44
45
|
readonly id: string
|
|
45
46
|
readonly path: string
|
|
46
|
-
readonly data: {
|
|
47
|
+
readonly data: {
|
|
48
|
+
readonly description?: string
|
|
49
|
+
readonly status?: WorkStatus
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface WorkTargetChoice {
|
|
@@ -51,13 +55,21 @@ export interface WorkTargetChoice {
|
|
|
51
55
|
readonly target: WorkTarget
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
const statusIcons: Record<WorkStatus, string> = {
|
|
59
|
+
open: "\x1b[2m○\x1b[0m",
|
|
60
|
+
working: "\x1b[34m◐\x1b[0m",
|
|
61
|
+
done: "\x1b[32m✓\x1b[0m",
|
|
62
|
+
dropped: "\x1b[31m⊘\x1b[0m",
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
const label = (
|
|
55
66
|
indent: string,
|
|
56
67
|
kind: WorkTarget["kind"],
|
|
57
68
|
id: string,
|
|
58
69
|
description?: string,
|
|
70
|
+
status?: WorkStatus,
|
|
59
71
|
) =>
|
|
60
|
-
`${indent}${
|
|
72
|
+
`${indent}${status === undefined ? "" : `${statusIcons[status]} `}${
|
|
61
73
|
{
|
|
62
74
|
epic: "\x1b[35m\x1b[0m",
|
|
63
75
|
task: "\x1b[36m\x1b[0m",
|
|
@@ -73,7 +85,13 @@ const taskChoices = (
|
|
|
73
85
|
const multiPhase = "phases" in task.data
|
|
74
86
|
const choices: WorkTargetChoice[] = [
|
|
75
87
|
{
|
|
76
|
-
label: label(
|
|
88
|
+
label: label(
|
|
89
|
+
indent,
|
|
90
|
+
"task",
|
|
91
|
+
task.id,
|
|
92
|
+
task.data.description,
|
|
93
|
+
multiPhase ? undefined : (task.data.status ?? "open"),
|
|
94
|
+
),
|
|
77
95
|
target: {
|
|
78
96
|
kind: "task",
|
|
79
97
|
taskId: task.id,
|
|
@@ -91,7 +109,13 @@ const taskChoices = (
|
|
|
91
109
|
if (!record) continue
|
|
92
110
|
renderedPhases.add(record.id)
|
|
93
111
|
choices.push({
|
|
94
|
-
label: label(
|
|
112
|
+
label: label(
|
|
113
|
+
`${indent} `,
|
|
114
|
+
"phase",
|
|
115
|
+
record.id,
|
|
116
|
+
record.data.description,
|
|
117
|
+
record.data.status ?? "open",
|
|
118
|
+
),
|
|
95
119
|
target: {
|
|
96
120
|
kind: "phase",
|
|
97
121
|
taskId: task.id,
|
|
@@ -103,7 +127,13 @@ const taskChoices = (
|
|
|
103
127
|
for (const record of phaseRecords) {
|
|
104
128
|
if (renderedPhases.has(record.id)) continue
|
|
105
129
|
choices.push({
|
|
106
|
-
label: label(
|
|
130
|
+
label: label(
|
|
131
|
+
`${indent} `,
|
|
132
|
+
"phase",
|
|
133
|
+
record.id,
|
|
134
|
+
record.data.description,
|
|
135
|
+
record.data.status ?? "open",
|
|
136
|
+
),
|
|
107
137
|
target: {
|
|
108
138
|
kind: "phase",
|
|
109
139
|
taskId: task.id,
|