@markjaquith/agency 2.4.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/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 +52 -33
- package/src/commands/work.ts +20 -8
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -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 +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- 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
|
@@ -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,
|