@markjaquith/agency 1.11.1 → 2.1.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 +313 -117
- package/cli.ts +124 -420
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +266 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +326 -399
- package/src/commands/work.ts +153 -153
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -641
- package/src/commands/push.ts +0 -482
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1187
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Schema } from "@effect/schema"
|
|
3
|
+
import {
|
|
4
|
+
EntityId,
|
|
5
|
+
EpicFrontmatter,
|
|
6
|
+
PhaseFrontmatter,
|
|
7
|
+
TaskFrontmatter,
|
|
8
|
+
WorkbaseConfig,
|
|
9
|
+
} from "./schemas"
|
|
10
|
+
|
|
11
|
+
describe("body-of-work descriptions", () => {
|
|
12
|
+
test("accepts descriptions on epics, tasks, and phases", () => {
|
|
13
|
+
const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
|
|
14
|
+
ticketUrl: "https://example.com/epic",
|
|
15
|
+
description: "Coordinate the rollout.",
|
|
16
|
+
repos: [{ repo: "agency", ref: "main" }],
|
|
17
|
+
tasks: [],
|
|
18
|
+
})
|
|
19
|
+
const singlePhaseTask = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
20
|
+
ticketUrl: "https://example.com/task",
|
|
21
|
+
description: "Deliver the rollout.",
|
|
22
|
+
repo: "agency",
|
|
23
|
+
branch: "task/rollout",
|
|
24
|
+
base: "main",
|
|
25
|
+
pr: null,
|
|
26
|
+
})
|
|
27
|
+
const multiPhaseTask = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
28
|
+
ticketUrl: "https://example.com/task",
|
|
29
|
+
description: "Deliver the rollout in phases.",
|
|
30
|
+
phases: [],
|
|
31
|
+
})
|
|
32
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
33
|
+
description: "Introduce the service contract.",
|
|
34
|
+
repo: "agency",
|
|
35
|
+
branch: "task/contract",
|
|
36
|
+
base: "main",
|
|
37
|
+
pr: null,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(epic.description).toBe("Coordinate the rollout.")
|
|
41
|
+
expect(singlePhaseTask.description).toBe("Deliver the rollout.")
|
|
42
|
+
expect(multiPhaseTask.description).toBe("Deliver the rollout in phases.")
|
|
43
|
+
expect(phase.description).toBe("Introduce the service contract.")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("allows omitted descriptions for existing documents", () => {
|
|
47
|
+
const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
|
|
48
|
+
ticketUrl: "https://example.com/epic",
|
|
49
|
+
repos: [{ repo: "agency", ref: "main" }],
|
|
50
|
+
tasks: [],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(epic.description).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("rejects an empty description when present", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
59
|
+
description: "",
|
|
60
|
+
repo: "agency",
|
|
61
|
+
branch: "task/contract",
|
|
62
|
+
base: "main",
|
|
63
|
+
pr: null,
|
|
64
|
+
}),
|
|
65
|
+
).toThrow()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("schema boundaries", () => {
|
|
70
|
+
const rejects = <S extends Schema.Schema.AnyNoContext>(
|
|
71
|
+
schema: S,
|
|
72
|
+
input: unknown,
|
|
73
|
+
) => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })(input),
|
|
76
|
+
).toThrow()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("rejects invalid boundary values", () => {
|
|
80
|
+
const cases = [
|
|
81
|
+
{
|
|
82
|
+
name: "unsupported config version",
|
|
83
|
+
schema: WorkbaseConfig,
|
|
84
|
+
input: { version: 1 },
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "excess config fields",
|
|
88
|
+
schema: WorkbaseConfig,
|
|
89
|
+
input: { version: 2, legacy: true },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "invalid entity ID",
|
|
93
|
+
schema: EntityId,
|
|
94
|
+
input: "invalid/id",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "empty epic repositories",
|
|
98
|
+
schema: EpicFrontmatter,
|
|
99
|
+
input: {
|
|
100
|
+
ticketUrl: "https://example.com/epic",
|
|
101
|
+
repos: [],
|
|
102
|
+
tasks: [],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "mixed task union",
|
|
107
|
+
schema: TaskFrontmatter,
|
|
108
|
+
input: {
|
|
109
|
+
ticketUrl: "https://example.com/task",
|
|
110
|
+
phases: [],
|
|
111
|
+
repo: "agency",
|
|
112
|
+
branch: "task/mixed",
|
|
113
|
+
base: "main",
|
|
114
|
+
pr: null,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "single-phase task missing pr",
|
|
119
|
+
schema: TaskFrontmatter,
|
|
120
|
+
input: {
|
|
121
|
+
ticketUrl: "https://example.com/task",
|
|
122
|
+
repo: "agency",
|
|
123
|
+
branch: "task/missing-pr",
|
|
124
|
+
base: "main",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "invalid phase PR URL",
|
|
129
|
+
schema: PhaseFrontmatter,
|
|
130
|
+
input: {
|
|
131
|
+
repo: "agency",
|
|
132
|
+
branch: "task/invalid-pr",
|
|
133
|
+
base: "main",
|
|
134
|
+
pr: "https://example.com/pull/1",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
] as const
|
|
138
|
+
|
|
139
|
+
for (const fixture of cases) {
|
|
140
|
+
expect(
|
|
141
|
+
() => rejects(fixture.schema, fixture.input),
|
|
142
|
+
fixture.name,
|
|
143
|
+
).not.toThrow()
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Schema } from "@effect/schema"
|
|
2
|
+
|
|
3
|
+
const NonEmptyString = Schema.String.pipe(Schema.minLength(1))
|
|
4
|
+
|
|
5
|
+
const Description = Schema.optional(NonEmptyString)
|
|
6
|
+
|
|
7
|
+
const IdPattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
8
|
+
|
|
9
|
+
export const EntityId = NonEmptyString.pipe(Schema.pattern(IdPattern))
|
|
10
|
+
|
|
11
|
+
export const RepositoryAlias = NonEmptyString.pipe(Schema.pattern(IdPattern))
|
|
12
|
+
|
|
13
|
+
export const RepositoryReference = Schema.Struct({
|
|
14
|
+
repo: RepositoryAlias,
|
|
15
|
+
ref: NonEmptyString,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
|
|
19
|
+
|
|
20
|
+
const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
21
|
+
Schema.pattern(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const WorkbaseConfig = Schema.Struct({
|
|
25
|
+
version: Schema.Literal(2),
|
|
26
|
+
worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const Dependency = Schema.Struct({
|
|
30
|
+
id: EntityId,
|
|
31
|
+
dependsOn: Schema.optional(Schema.Array(EntityId)),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const ExecutionUnit = {
|
|
35
|
+
repo: RepositoryAlias,
|
|
36
|
+
repos: Schema.optional(Schema.Array(RepositoryReference)),
|
|
37
|
+
branch: NonEmptyString,
|
|
38
|
+
base: NonEmptyString,
|
|
39
|
+
pr: Schema.NullOr(GitHubPullRequestUrl),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const EpicFrontmatter = Schema.Struct({
|
|
43
|
+
ticketUrl: Url,
|
|
44
|
+
description: Description,
|
|
45
|
+
repos: Schema.NonEmptyArray(RepositoryReference),
|
|
46
|
+
tasks: Schema.Array(Dependency),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const SinglePhaseTaskFrontmatter = Schema.Struct({
|
|
50
|
+
ticketUrl: Url,
|
|
51
|
+
description: Description,
|
|
52
|
+
epic: Schema.optional(EntityId),
|
|
53
|
+
...ExecutionUnit,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const MultiPhaseTaskFrontmatter = Schema.Struct({
|
|
57
|
+
ticketUrl: Url,
|
|
58
|
+
description: Description,
|
|
59
|
+
epic: Schema.optional(EntityId),
|
|
60
|
+
phases: Schema.Array(Dependency),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const TaskFrontmatter = Schema.Union(
|
|
64
|
+
SinglePhaseTaskFrontmatter,
|
|
65
|
+
MultiPhaseTaskFrontmatter,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
export const PhaseFrontmatter = Schema.Struct({
|
|
69
|
+
description: Description,
|
|
70
|
+
...ExecutionUnit,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
|
|
74
|
+
export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
75
|
+
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
76
|
+
export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
|
|
77
|
+
export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
|
|
78
|
+
export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { buildWorkTargetChoices } from "./work-target"
|
|
3
|
+
|
|
4
|
+
describe("work target choices", () => {
|
|
5
|
+
test("builds an ordered hierarchy with standalone tasks", () => {
|
|
6
|
+
const choices = buildWorkTargetChoices(
|
|
7
|
+
[
|
|
8
|
+
{
|
|
9
|
+
id: "delivery",
|
|
10
|
+
path: "/workbase/epics/delivery/EPIC.md",
|
|
11
|
+
data: {
|
|
12
|
+
description: "Ship the release",
|
|
13
|
+
tasks: [{ id: "multi" }, { id: "single" }],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
id: "multi",
|
|
20
|
+
path: "/workbase/tasks/multi/TASK.md",
|
|
21
|
+
data: {
|
|
22
|
+
phases: [{ id: "build" }, { id: "verify" }],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "single",
|
|
27
|
+
path: "/workbase/tasks/single/TASK.md",
|
|
28
|
+
data: {},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "standalone",
|
|
32
|
+
path: "/workbase/tasks/standalone/TASK.md",
|
|
33
|
+
data: { description: "Independent work" },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
taskId: "multi",
|
|
39
|
+
id: "verify",
|
|
40
|
+
path: "/workbase/tasks/multi/phases/verify/PHASE.md",
|
|
41
|
+
data: {},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
taskId: "multi",
|
|
45
|
+
id: "build",
|
|
46
|
+
path: "/workbase/tasks/multi/phases/build/PHASE.md",
|
|
47
|
+
data: {},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
taskId: "multi",
|
|
51
|
+
id: "unlisted",
|
|
52
|
+
path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
|
|
53
|
+
data: {},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(choices.map((choice) => choice.label)).toEqual([
|
|
59
|
+
"epic delivery - Ship the release",
|
|
60
|
+
" task multi",
|
|
61
|
+
" phase build",
|
|
62
|
+
" phase verify",
|
|
63
|
+
" phase unlisted",
|
|
64
|
+
" task single",
|
|
65
|
+
"task standalone - Independent work",
|
|
66
|
+
])
|
|
67
|
+
expect(choices.map((choice) => choice.target.kind)).toEqual([
|
|
68
|
+
"epic",
|
|
69
|
+
"task",
|
|
70
|
+
"phase",
|
|
71
|
+
"phase",
|
|
72
|
+
"phase",
|
|
73
|
+
"task",
|
|
74
|
+
"task",
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
|
|
3
|
+
export type WorkTarget =
|
|
4
|
+
| {
|
|
5
|
+
readonly kind: "epic"
|
|
6
|
+
readonly epicId: string
|
|
7
|
+
readonly path: string
|
|
8
|
+
}
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: "task"
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly path: string
|
|
13
|
+
readonly multiPhase: boolean
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "phase"
|
|
17
|
+
readonly taskId: string
|
|
18
|
+
readonly phaseId: string
|
|
19
|
+
readonly path: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface EpicRecord {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly path: string
|
|
25
|
+
readonly data: {
|
|
26
|
+
readonly description?: string
|
|
27
|
+
readonly tasks: readonly { readonly id: string }[]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TaskRecord {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly path: string
|
|
34
|
+
readonly data:
|
|
35
|
+
| {
|
|
36
|
+
readonly description?: string
|
|
37
|
+
readonly phases: readonly { readonly id: string }[]
|
|
38
|
+
}
|
|
39
|
+
| { readonly description?: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PhaseRecord {
|
|
43
|
+
readonly taskId: string
|
|
44
|
+
readonly id: string
|
|
45
|
+
readonly path: string
|
|
46
|
+
readonly data: { readonly description?: string }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkTargetChoice {
|
|
50
|
+
readonly label: string
|
|
51
|
+
readonly target: WorkTarget
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const label = (
|
|
55
|
+
indent: string,
|
|
56
|
+
kind: WorkTarget["kind"],
|
|
57
|
+
id: string,
|
|
58
|
+
description?: string,
|
|
59
|
+
) =>
|
|
60
|
+
`${indent}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
|
|
61
|
+
|
|
62
|
+
const taskChoices = (
|
|
63
|
+
task: TaskRecord,
|
|
64
|
+
phaseRecords: readonly PhaseRecord[],
|
|
65
|
+
indent: string,
|
|
66
|
+
): readonly WorkTargetChoice[] => {
|
|
67
|
+
const multiPhase = "phases" in task.data
|
|
68
|
+
const choices: WorkTargetChoice[] = [
|
|
69
|
+
{
|
|
70
|
+
label: label(indent, "task", task.id, task.data.description),
|
|
71
|
+
target: {
|
|
72
|
+
kind: "task",
|
|
73
|
+
taskId: task.id,
|
|
74
|
+
path: task.path,
|
|
75
|
+
multiPhase,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
if (!multiPhase) return choices
|
|
80
|
+
|
|
81
|
+
const phases = new Map(phaseRecords.map((phase) => [phase.id, phase]))
|
|
82
|
+
const renderedPhases = new Set<string>()
|
|
83
|
+
for (const phase of task.data.phases) {
|
|
84
|
+
const record = phases.get(phase.id)
|
|
85
|
+
if (!record) continue
|
|
86
|
+
renderedPhases.add(record.id)
|
|
87
|
+
choices.push({
|
|
88
|
+
label: label(`${indent} `, "phase", record.id, record.data.description),
|
|
89
|
+
target: {
|
|
90
|
+
kind: "phase",
|
|
91
|
+
taskId: task.id,
|
|
92
|
+
phaseId: record.id,
|
|
93
|
+
path: record.path,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
for (const record of phaseRecords) {
|
|
98
|
+
if (renderedPhases.has(record.id)) continue
|
|
99
|
+
choices.push({
|
|
100
|
+
label: label(`${indent} `, "phase", record.id, record.data.description),
|
|
101
|
+
target: {
|
|
102
|
+
kind: "phase",
|
|
103
|
+
taskId: task.id,
|
|
104
|
+
phaseId: record.id,
|
|
105
|
+
path: record.path,
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return choices
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const buildWorkTargetChoices = (
|
|
113
|
+
epicRecords: readonly EpicRecord[],
|
|
114
|
+
taskRecords: readonly TaskRecord[],
|
|
115
|
+
phaseRecords: readonly PhaseRecord[],
|
|
116
|
+
): readonly WorkTargetChoice[] => {
|
|
117
|
+
const choices: WorkTargetChoice[] = []
|
|
118
|
+
const tasks = new Map(taskRecords.map((task) => [task.id, task]))
|
|
119
|
+
const phases = new Map<string, PhaseRecord[]>()
|
|
120
|
+
for (const phase of phaseRecords) {
|
|
121
|
+
const records = phases.get(phase.taskId) ?? []
|
|
122
|
+
records.push(phase)
|
|
123
|
+
phases.set(phase.taskId, records)
|
|
124
|
+
}
|
|
125
|
+
const nestedTasks = new Set<string>()
|
|
126
|
+
|
|
127
|
+
for (const epic of epicRecords) {
|
|
128
|
+
choices.push({
|
|
129
|
+
label: label("", "epic", epic.id, epic.data.description),
|
|
130
|
+
target: { kind: "epic", epicId: epic.id, path: epic.path },
|
|
131
|
+
})
|
|
132
|
+
for (const child of epic.data.tasks) {
|
|
133
|
+
const task = tasks.get(child.id)
|
|
134
|
+
if (!task || nestedTasks.has(task.id)) continue
|
|
135
|
+
nestedTasks.add(task.id)
|
|
136
|
+
choices.push(...taskChoices(task, phases.get(task.id) ?? [], " "))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const task of taskRecords) {
|
|
141
|
+
if (nestedTasks.has(task.id)) continue
|
|
142
|
+
choices.push(...taskChoices(task, phases.get(task.id) ?? [], ""))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return choices
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type PickWorkTarget = (
|
|
149
|
+
choices: readonly WorkTargetChoice[],
|
|
150
|
+
) => Effect.Effect<WorkTarget | null, Error>
|
|
151
|
+
|
|
152
|
+
const parseWorkTargetSelection = (
|
|
153
|
+
output: string,
|
|
154
|
+
choices: readonly WorkTargetChoice[],
|
|
155
|
+
) => {
|
|
156
|
+
const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
|
|
157
|
+
return choices[index]?.target ?? null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const pickWorkTarget: PickWorkTarget = (choices) =>
|
|
161
|
+
Effect.tryPromise({
|
|
162
|
+
try: async () => {
|
|
163
|
+
const input = choices
|
|
164
|
+
.map((choice, index) => `${index}\t${choice.label}`)
|
|
165
|
+
.join("\n")
|
|
166
|
+
const process = Bun.spawn(
|
|
167
|
+
["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Work on> "],
|
|
168
|
+
{ stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
|
|
169
|
+
)
|
|
170
|
+
const [exitCode, output] = await Promise.all([
|
|
171
|
+
process.exited,
|
|
172
|
+
new Response(process.stdout).text(),
|
|
173
|
+
])
|
|
174
|
+
if (exitCode === 1 || exitCode === 130) return null
|
|
175
|
+
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
176
|
+
return parseWorkTargetSelection(output, choices)
|
|
177
|
+
},
|
|
178
|
+
catch: (cause) =>
|
|
179
|
+
new Error("Failed to select a work target with fzf", { cause }),
|
|
180
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
expandWorktreeCreateCommand,
|
|
4
|
+
worktreeCommandEnvironment,
|
|
5
|
+
} from "./worktree-command"
|
|
6
|
+
|
|
7
|
+
const variables = {
|
|
8
|
+
repo: "/work/repos/app",
|
|
9
|
+
worktree: "/work/tasks/example/code/app",
|
|
10
|
+
branch: "task/example",
|
|
11
|
+
base: "main",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("worktree command templates", () => {
|
|
15
|
+
test("expands argv placeholders without shell interpolation", () => {
|
|
16
|
+
expect(
|
|
17
|
+
expandWorktreeCreateCommand(
|
|
18
|
+
[
|
|
19
|
+
"tool",
|
|
20
|
+
"--repo={repo}",
|
|
21
|
+
"--worktree",
|
|
22
|
+
"{worktree}",
|
|
23
|
+
"{branch}",
|
|
24
|
+
"{base}",
|
|
25
|
+
],
|
|
26
|
+
variables,
|
|
27
|
+
),
|
|
28
|
+
).toEqual([
|
|
29
|
+
"tool",
|
|
30
|
+
"--repo=/work/repos/app",
|
|
31
|
+
"--worktree",
|
|
32
|
+
"/work/tasks/example/code/app",
|
|
33
|
+
"task/example",
|
|
34
|
+
"main",
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("requires repo and worktree placeholders", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
expandWorktreeCreateCommand(["tool", "{repo}"], variables),
|
|
41
|
+
).toThrow("{worktree}")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("rejects unknown placeholders", () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
expandWorktreeCreateCommand(
|
|
47
|
+
["tool", "{repo}", "{worktree}", "{unknown}"],
|
|
48
|
+
variables,
|
|
49
|
+
),
|
|
50
|
+
).toThrow("{unknown}")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("provides equivalent environment variables", () => {
|
|
54
|
+
expect(worktreeCommandEnvironment(variables)).toEqual({
|
|
55
|
+
AGENCY_REPO: variables.repo,
|
|
56
|
+
AGENCY_WORKTREE: variables.worktree,
|
|
57
|
+
AGENCY_BRANCH: variables.branch,
|
|
58
|
+
AGENCY_BASE: variables.base,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface WorktreeCommandVariables {
|
|
2
|
+
readonly repo: string
|
|
3
|
+
readonly worktree: string
|
|
4
|
+
readonly branch: string
|
|
5
|
+
readonly base: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const REQUIRED_PLACEHOLDERS = ["repo", "worktree"] as const
|
|
9
|
+
const PLACEHOLDERS = new Set(["repo", "worktree", "branch", "base"])
|
|
10
|
+
|
|
11
|
+
export const validateWorktreeCreateCommand = (command: readonly string[]) => {
|
|
12
|
+
const template = command.join("\u0000")
|
|
13
|
+
for (const placeholder of REQUIRED_PLACEHOLDERS) {
|
|
14
|
+
if (!template.includes(`{${placeholder}}`)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`worktreeCreateCommand must include the {${placeholder}} placeholder`,
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const argument of command) {
|
|
21
|
+
for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
|
|
22
|
+
const placeholder = match[1]!
|
|
23
|
+
if (!PLACEHOLDERS.has(placeholder)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Unknown worktreeCreateCommand placeholder: {${placeholder}}`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const expandWorktreeCreateCommand = (
|
|
33
|
+
command: readonly string[],
|
|
34
|
+
variables: WorktreeCommandVariables,
|
|
35
|
+
): string[] => {
|
|
36
|
+
validateWorktreeCreateCommand(command)
|
|
37
|
+
|
|
38
|
+
return command.map((argument) =>
|
|
39
|
+
argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
|
|
40
|
+
return variables[placeholder as keyof WorktreeCommandVariables] ?? match
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const worktreeCommandEnvironment = (
|
|
46
|
+
variables: WorktreeCommandVariables,
|
|
47
|
+
): Record<string, string> => ({
|
|
48
|
+
AGENCY_REPO: variables.repo,
|
|
49
|
+
AGENCY_WORKTREE: variables.worktree,
|
|
50
|
+
AGENCY_BRANCH: variables.branch,
|
|
51
|
+
AGENCY_BASE: variables.base,
|
|
52
|
+
})
|