@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.
Files changed (37) hide show
  1. package/README.md +45 -16
  2. package/cli.ts +21 -0
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +31 -4
  5. package/src/cli.test.ts +1 -0
  6. package/src/commands/archive.test.ts +69 -0
  7. package/src/commands/archive.ts +70 -0
  8. package/src/commands/init.test.ts +3 -0
  9. package/src/commands/phase.ts +23 -1
  10. package/src/commands/pr.ts +1 -0
  11. package/src/commands/task-phase.test.ts +46 -1
  12. package/src/commands/task.test.ts +94 -0
  13. package/src/commands/task.ts +160 -17
  14. package/src/commands/work.test.ts +63 -34
  15. package/src/commands/work.ts +20 -3
  16. package/src/services/ArchiveService.test.ts +334 -0
  17. package/src/services/ArchiveService.ts +246 -0
  18. package/src/services/FileSystemService.ts +7 -2
  19. package/src/services/PhaseService.ts +28 -0
  20. package/src/services/PullRequestService.ts +3 -0
  21. package/src/services/TaskPhaseService.test.ts +79 -0
  22. package/src/services/TaskService.ts +31 -1
  23. package/src/services/WorkbaseService.test.ts +66 -0
  24. package/src/services/WorkbaseService.ts +32 -2
  25. package/src/services/WorktreeService.test.ts +210 -2
  26. package/src/services/WorktreeService.ts +143 -1
  27. package/src/test-utils.ts +2 -0
  28. package/src/utils/process.test.ts +38 -1
  29. package/src/utils/process.ts +30 -6
  30. package/src/utils/progress.test.ts +37 -0
  31. package/src/utils/progress.ts +36 -0
  32. package/src/workbase/AGENTS.md +3 -0
  33. package/src/workbase/opencode-file.ts +37 -0
  34. package/src/workbase/schemas.test.ts +50 -0
  35. package/src/workbase/schemas.ts +6 -2
  36. package/src/workbase/work-target.test.ts +9 -9
  37. 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 = [
@@ -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 ?? "pipe",
54
- stderr: options?.stderr ?? "pipe",
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
- : new Response(proc.stdout ?? "").text()
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
- : new Response(proc.stderr ?? "").text()
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
+ }
@@ -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,
@@ -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: { readonly description?: string }
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(indent, "task", task.id, task.data.description),
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(`${indent} `, "phase", record.id, record.data.description),
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(`${indent} `, "phase", record.id, record.data.description),
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,