@markjaquith/agency 1.11.0 → 2.0.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 (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -1,140 +1,32 @@
1
- import { basename } from "path"
2
1
  import { Effect } from "effect"
3
2
  import type { BaseCommandOptions } from "../utils/command"
4
- import { GitService } from "../services/GitService"
5
- import { PromptService } from "../services/PromptService"
6
- import { TemplateService } from "../services/TemplateService"
7
- import highlight, { done } from "../utils/colors"
8
- import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
3
+ import { WorkbaseService } from "../services/WorkbaseService"
4
+ import { createLoggers } from "../utils/effect"
9
5
 
10
6
  interface InitOptions extends BaseCommandOptions {
11
- template?: string
7
+ readonly path?: string
12
8
  }
13
9
 
14
10
  export const init = (options: InitOptions = {}) =>
15
11
  Effect.gen(function* () {
16
- const { silent = false, cwd } = options
17
- const { log, verboseLog } = createLoggers(options)
18
-
19
- const git = yield* GitService
20
- const promptService = yield* PromptService
21
- const templateService = yield* TemplateService
22
-
23
- const gitRoot = yield* ensureGitRepo(cwd)
24
-
25
- // Check if already initialized
26
- const existingTemplate = yield* getTemplateName(gitRoot)
27
- if (existingTemplate && !options.template) {
28
- return yield* Effect.fail(
29
- new Error(
30
- `Already initialized with template ${highlight.template(existingTemplate)}.\n` +
31
- `To change template, run: agency init --template <name>`,
32
- ),
33
- )
34
- }
35
-
36
- let templateName = options.template
37
-
38
- // If template name not provided, show interactive selection
39
- if (!templateName) {
40
- if (silent) {
41
- return yield* Effect.fail(
42
- new Error(
43
- "Template name required. Use --template flag in silent mode.",
44
- ),
45
- )
46
- }
47
-
48
- const existingTemplates = yield* templateService.listTemplates()
49
- verboseLog(`Found ${existingTemplates.length} existing templates`)
50
-
51
- // Get current directory name as default suggestion
52
- let defaultTemplateName: string | undefined
53
- const dirName = basename(gitRoot)
54
- const sanitizedDirName =
55
- yield* promptService.sanitizeTemplateName(dirName)
56
-
57
- if (sanitizedDirName && !existingTemplates.includes(sanitizedDirName)) {
58
- defaultTemplateName = sanitizedDirName
59
- verboseLog(`Suggesting default template name: ${defaultTemplateName}`)
60
- }
61
-
62
- const selectedName = yield* promptService.promptForTemplate(
63
- existingTemplates,
64
- {
65
- defaultValue: defaultTemplateName,
66
- allowNew: true,
67
- },
68
- )
69
-
70
- // If selected from list, use as-is; otherwise sanitize new name
71
- if (existingTemplates.includes(selectedName)) {
72
- templateName = selectedName
73
- } else {
74
- templateName = yield* promptService.sanitizeTemplateName(selectedName)
75
- }
76
-
77
- verboseLog(`Selected template: ${templateName}`)
78
- }
79
-
80
- if (!templateName) {
81
- return yield* Effect.fail(new Error("Template name is required."))
82
- }
83
-
84
- // Resolve and save default remote (with smart precedence)
85
- const remote = yield* git
86
- .resolveRemote(gitRoot)
87
- .pipe(Effect.catchAll(() => Effect.succeed(null)))
88
-
89
- if (remote) {
90
- yield* git.setRemoteConfig(remote, gitRoot)
91
- verboseLog(`Detected and saved remote: ${remote}`)
92
- } else {
93
- verboseLog("No remote detected - skip remote configuration")
94
- }
95
-
96
- // Save template name to git config
97
- yield* git.setGitConfig("agency.template", templateName, gitRoot)
98
- log(
99
- done(
100
- `Initialized with template ${highlight.template(templateName)}${existingTemplate && existingTemplate !== templateName ? ` (was ${highlight.template(existingTemplate)})` : ""}`,
101
- ),
12
+ const workbase = yield* WorkbaseService
13
+ const { log } = createLoggers(options)
14
+ const root = yield* workbase.initialize(
15
+ options.path ?? options.cwd ?? process.cwd(),
102
16
  )
103
-
104
- // Note: We do NOT create the template directory here
105
- // It will be created when the user runs 'agency template save'
106
- verboseLog(
107
- `Template directory will be created when you save files with 'agency template save'`,
17
+ log(
18
+ options.json
19
+ ? JSON.stringify({ root }, null, 2)
20
+ : `Initialized Agency workbase at ${root}`,
108
21
  )
109
22
  })
110
23
 
111
24
  export const help = `
112
- Usage: agency init [options]
25
+ Usage: agency init [path]
113
26
 
114
- Initialize agency for this repository by selecting a template.
115
-
116
- This command must be run before using other agency commands like 'agency task'.
117
- It saves your template selection to .git/config (not committed to the repository).
118
-
119
- On first run, you'll be prompted to either:
120
- - Select an existing template from your ~/.config/agency/templates/
121
- - Create a new template by entering a new name
122
-
123
- If no templates exist, the default suggestion is the current directory name.
124
-
125
- The template directory itself is only created when you actually save files to it
126
- using 'agency template save'.
27
+ Initialize an Agency workbase. The current directory is used when path is
28
+ omitted.
127
29
 
128
30
  Options:
129
- -t, --template Specify template name (skips interactive prompt)
130
-
131
- Examples:
132
- agency init # Interactive template selection
133
- agency init --template work # Set template to 'work'
134
-
135
- Notes:
136
- - Template name is saved to .git/config (agency.template)
137
- - Template directory is NOT created until 'agency template save' is used
138
- - To change template later, run 'agency init --template <name>'
139
- - Run 'agency task' after initialization to create template files
31
+ --json Output the initialized workbase as JSON
140
32
  `
@@ -0,0 +1,120 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { PhaseService } from "../services/PhaseService"
4
+ import { createLoggers } from "../utils/effect"
5
+ import { parseRepositoryReferences } from "../workbase/repository-reference"
6
+
7
+ interface PhaseOptions extends BaseCommandOptions {
8
+ readonly subcommand?: string
9
+ readonly args: readonly string[]
10
+ readonly description?: string
11
+ readonly repo?: string
12
+ readonly references?: readonly string[]
13
+ readonly branch?: string
14
+ readonly base?: string
15
+ readonly dependsOn?: readonly string[]
16
+ readonly firstPhase?: string
17
+ readonly json?: boolean
18
+ }
19
+
20
+ export const phase = (options: PhaseOptions) =>
21
+ Effect.gen(function* () {
22
+ const phases = yield* PhaseService
23
+ const { log } = createLoggers(options)
24
+ const cwd = options.cwd ?? process.cwd()
25
+ const [taskId, phaseId] = options.args
26
+
27
+ switch (options.subcommand) {
28
+ case "create": {
29
+ if (
30
+ !taskId ||
31
+ !phaseId ||
32
+ !options.repo ||
33
+ !options.branch ||
34
+ !options.base
35
+ ) {
36
+ return yield* Effect.fail(
37
+ new Error(
38
+ "Usage: agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name>",
39
+ ),
40
+ )
41
+ }
42
+ const record = yield* phases.create(
43
+ {
44
+ taskId,
45
+ id: phaseId,
46
+ description: options.description,
47
+ repo: options.repo,
48
+ repos: parseRepositoryReferences(options.references),
49
+ branch: options.branch,
50
+ base: options.base,
51
+ dependsOn: options.dependsOn,
52
+ firstPhase: options.firstPhase,
53
+ },
54
+ cwd,
55
+ )
56
+ const { content: _, ...output } = record
57
+ log(
58
+ options.json
59
+ ? JSON.stringify(output, null, 2)
60
+ : `Created phase '${record.id}' on task '${record.taskId}'`,
61
+ )
62
+ return
63
+ }
64
+ case "list": {
65
+ if (!taskId) return yield* Effect.fail(new Error("Task ID is required"))
66
+ const records = yield* phases.list(taskId, cwd)
67
+ if (options.json) {
68
+ log(
69
+ JSON.stringify(
70
+ records.map(({ content: _, ...record }) => record),
71
+ null,
72
+ 2,
73
+ ),
74
+ )
75
+ } else for (const record of records) log(record.id)
76
+ return
77
+ }
78
+ case "show": {
79
+ if (!taskId || !phaseId) {
80
+ return yield* Effect.fail(
81
+ new Error("Task ID and phase ID are required"),
82
+ )
83
+ }
84
+ const record = yield* phases.show(taskId, phaseId, cwd)
85
+ const { content: _, ...output } = record
86
+ log(
87
+ options.json
88
+ ? JSON.stringify(output, null, 2)
89
+ : record.content.trimEnd(),
90
+ )
91
+ return
92
+ }
93
+ default:
94
+ return yield* Effect.fail(
95
+ new Error("Subcommand is required. Available: create, list, show"),
96
+ )
97
+ }
98
+ })
99
+
100
+ export const help = `
101
+ Usage: agency phase <subcommand> <task-id> [phase-id]
102
+
103
+ Subcommands:
104
+ create <task> <phase> Create a phase
105
+ list <task> List task phases
106
+ show <task> <phase> Show a phase
107
+
108
+ Create options:
109
+ --description <text> Short description of the phase
110
+ --repo <alias> Writable repository
111
+ --reference <alias>:<ref>
112
+ Read-only repository reference; repeatable
113
+ --branch <name> Working branch
114
+ --base <name> Base branch
115
+ --depends-on <id> Phase dependency; repeatable
116
+ --first-phase <id> Existing execution phase ID when converting a task
117
+
118
+ Options:
119
+ --json Output results as JSON
120
+ `
@@ -1,177 +1,26 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { chmod, mkdir } from "node:fs/promises"
3
- import { join } from "node:path"
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { captureLogs } from "../test-utils"
4
+ import { PullRequestService } from "../services/PullRequestService"
4
5
  import { pr } from "./pr"
5
- import {
6
- cleanupTempDir,
7
- createBranch,
8
- createTempDir,
9
- initGitRepo,
10
- runTestEffect,
11
- } from "../test-utils"
12
-
13
- const restoreEnv = (key: string, value: string | undefined) => {
14
- if (value === undefined) {
15
- delete process.env[key]
16
- return
17
- }
18
-
19
- process.env[key] = value
20
- }
21
-
22
- const readGhArgs = async (recordPath: string): Promise<string[]> => {
23
- const file = Bun.file(recordPath)
24
-
25
- if (!(await file.exists())) {
26
- return []
27
- }
28
-
29
- const content = await file.text()
30
- return content.trim().split("\n").filter(Boolean)
31
- }
32
6
 
33
7
  describe("pr command", () => {
34
- let tempDir: string
35
- let recordPath: string
36
- let originalCwd: string
37
- let originalPath: string | undefined
38
- let originalAgencyConfigPath: string | undefined
39
- let originalGhArgsFile: string | undefined
40
-
41
- beforeEach(async () => {
42
- tempDir = await createTempDir()
43
- recordPath = join(tempDir, "gh-args.txt")
44
- originalCwd = process.cwd()
45
- originalPath = process.env.PATH
46
- originalAgencyConfigPath = process.env.AGENCY_CONFIG_PATH
47
- originalGhArgsFile = process.env.AGENCY_TEST_GH_ARGS_FILE
48
-
49
- process.chdir(tempDir)
50
- process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
51
- process.env.AGENCY_TEST_GH_ARGS_FILE = recordPath
52
-
53
- const binDir = join(tempDir, "bin")
54
- const ghPath = join(binDir, "gh")
55
- await mkdir(binDir)
56
- await Bun.write(
57
- ghPath,
58
- `#!/bin/sh
59
- : > "$AGENCY_TEST_GH_ARGS_FILE"
60
- for arg in "$@"; do
61
- printf '%s\n' "$arg" >> "$AGENCY_TEST_GH_ARGS_FILE"
62
- done
63
- `,
64
- )
65
- await chmod(ghPath, 0o755)
66
- process.env.PATH = `${binDir}:${originalPath ?? ""}`
67
-
68
- await initGitRepo(tempDir)
69
- })
70
-
71
- afterEach(async () => {
72
- process.chdir(originalCwd)
73
- restoreEnv("PATH", originalPath)
74
- restoreEnv("AGENCY_CONFIG_PATH", originalAgencyConfigPath)
75
- restoreEnv("AGENCY_TEST_GH_ARGS_FILE", originalGhArgsFile)
76
- await cleanupTempDir(tempDir)
77
- })
78
-
79
- test("passes through to gh pr unchanged outside agency context", async () => {
80
- await createBranch(tempDir, "feature")
81
-
82
- await runTestEffect(pr({ args: ["status"], silent: false }))
83
-
84
- expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
85
- })
86
-
87
- test("passes through outside agency context with custom emit pattern", async () => {
88
- const configPath = process.env.AGENCY_CONFIG_PATH
89
-
90
- if (!configPath) {
91
- throw new Error("AGENCY_CONFIG_PATH is not set")
92
- }
93
-
94
- await Bun.write(
95
- configPath,
96
- JSON.stringify({
97
- sourceBranchPattern: "agency--%branch%",
98
- emitBranch: "%branch%--PR",
99
- }),
8
+ test("outputs the created pull request URL as JSON", async () => {
9
+ const url = "https://github.com/markjaquith/agency/pull/123"
10
+ const logs = await captureLogs(() =>
11
+ Effect.runPromise(
12
+ pr({
13
+ subcommand: "create",
14
+ taskId: "example",
15
+ json: true,
16
+ }).pipe(
17
+ Effect.provideService(PullRequestService, {
18
+ create: () => Effect.succeed(url),
19
+ } as never),
20
+ ) as Effect.Effect<void, unknown, never>,
21
+ ),
100
22
  )
101
- await createBranch(tempDir, "feature")
102
-
103
- await runTestEffect(pr({ args: ["status"], silent: false }))
104
-
105
- expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
106
- })
107
-
108
- test("appends emitted branch in agency context", async () => {
109
- await createBranch(tempDir, "agency--feature")
110
-
111
- await runTestEffect(pr({ args: ["view", "--web"], silent: false }))
112
-
113
- expect(await readGhArgs(recordPath)).toEqual([
114
- "pr",
115
- "view",
116
- "--web",
117
- "feature",
118
- ])
119
- })
120
-
121
- test("appends emitted branch with gh pr value flags when no selector is provided", async () => {
122
- await createBranch(tempDir, "agency--feature")
123
-
124
- await runTestEffect(
125
- pr({
126
- args: ["view", "--json", "number", "--jq", ".number"],
127
- silent: false,
128
- }),
129
- )
130
-
131
- expect(await readGhArgs(recordPath)).toEqual([
132
- "pr",
133
- "view",
134
- "--json",
135
- "number",
136
- "--jq",
137
- ".number",
138
- "feature",
139
- ])
140
- })
141
-
142
- test("does not append emitted branch when an explicit selector is provided", async () => {
143
- await createBranch(tempDir, "agency--feature")
144
-
145
- await runTestEffect(
146
- pr({
147
- args: [
148
- "view",
149
- "123",
150
- "--json",
151
- "statusCheckRollup",
152
- "--jq",
153
- ".statusCheckRollup[]",
154
- ],
155
- silent: false,
156
- }),
157
- )
158
-
159
- expect(await readGhArgs(recordPath)).toEqual([
160
- "pr",
161
- "view",
162
- "123",
163
- "--json",
164
- "statusCheckRollup",
165
- "--jq",
166
- ".statusCheckRollup[]",
167
- ])
168
- })
169
-
170
- test("does not append emitted branch for subcommands without a selector", async () => {
171
- await createBranch(tempDir, "agency--feature")
172
-
173
- await runTestEffect(pr({ args: ["status"], silent: false }))
174
23
 
175
- expect(await readGhArgs(recordPath)).toEqual(["pr", "status"])
24
+ expect(JSON.parse(logs[0]!)).toEqual({ url })
176
25
  })
177
26
  })
@@ -1,196 +1,40 @@
1
1
  import { Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
- import { GitService } from "../services/GitService"
4
- import { ConfigService } from "../services/ConfigService"
5
- import { resolveAgencyBranchPairWithAgencyJson } from "../utils/pr-branch"
6
- import { ensureGitRepo } from "../utils/effect"
3
+ import { PullRequestService } from "../services/PullRequestService"
4
+ import { createLoggers } from "../utils/effect"
7
5
 
8
6
  interface PrOptions extends BaseCommandOptions {
9
- /** Arguments to pass to gh pr (subcommand and flags) */
10
- args: string[]
11
- }
12
-
13
- const PR_SUBCOMMANDS_WITH_OPTIONAL_SELECTOR = new Set([
14
- "checkout",
15
- "checks",
16
- "close",
17
- "comment",
18
- "diff",
19
- "edit",
20
- "lock",
21
- "merge",
22
- "ready",
23
- "reopen",
24
- "review",
25
- "unlock",
26
- "update-branch",
27
- "view",
28
- ])
29
-
30
- const GH_PR_FLAGS_WITH_VALUE = new Set([
31
- "--add-assignee",
32
- "--add-label",
33
- "--add-project",
34
- "--add-reviewer",
35
- "--app",
36
- "--assignee",
37
- "--author",
38
- "--base",
39
- "--body",
40
- "--body-file",
41
- "--head",
42
- "--hostname",
43
- "--json",
44
- "--jq",
45
- "--label",
46
- "--limit",
47
- "--match-head-commit",
48
- "--milestone",
49
- "--project",
50
- "--remove-assignee",
51
- "--remove-label",
52
- "--remove-project",
53
- "--remove-reviewer",
54
- "--repo",
55
- "--reviewer",
56
- "--search",
57
- "--state",
58
- "--template",
59
- "--title",
60
- ])
61
-
62
- const GH_PR_SHORT_FLAGS_WITH_VALUE = new Set([
63
- "-A",
64
- "-B",
65
- "-H",
66
- "-L",
67
- "-R",
68
- "-a",
69
- "-b",
70
- "-l",
71
- "-m",
72
- "-p",
73
- "-q",
74
- "-r",
75
- "-s",
76
- "-t",
77
- ])
78
-
79
- const hasExplicitPrSelector = (args: readonly string[]): boolean => {
80
- const subcommandArgs = args.slice(1)
81
-
82
- for (let i = 0; i < subcommandArgs.length; i++) {
83
- const arg = subcommandArgs[i]
84
-
85
- if (!arg) {
86
- continue
87
- }
88
-
89
- if (arg === "--") {
90
- return subcommandArgs.slice(i + 1).some(Boolean)
91
- }
92
-
93
- if (arg.startsWith("--")) {
94
- const flagName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg
95
-
96
- if (!arg.includes("=") && GH_PR_FLAGS_WITH_VALUE.has(flagName)) {
97
- i++
98
- }
99
-
100
- continue
101
- }
102
-
103
- if (arg.startsWith("-") && arg.length > 1) {
104
- if (GH_PR_SHORT_FLAGS_WITH_VALUE.has(arg)) {
105
- i++
106
- }
107
-
108
- continue
109
- }
110
-
111
- return true
112
- }
113
-
114
- return false
115
- }
116
-
117
- const shouldAppendEmitBranch = (args: readonly string[]): boolean => {
118
- const subcommand = args[0]
119
-
120
- return (
121
- typeof subcommand === "string" &&
122
- PR_SUBCOMMANDS_WITH_OPTIONAL_SELECTOR.has(subcommand) &&
123
- !hasExplicitPrSelector(args)
124
- )
7
+ readonly subcommand?: string
8
+ readonly taskId?: string
9
+ readonly phaseId?: string
10
+ readonly draft?: boolean
125
11
  }
126
12
 
127
13
  export const pr = (options: PrOptions) =>
128
14
  Effect.gen(function* () {
129
- const git = yield* GitService
130
- const configService = yield* ConfigService
131
-
132
- const gitRoot = yield* ensureGitRepo()
133
-
134
- // Load config
135
- const config = yield* configService.loadConfig()
136
-
137
- // Get current branch and resolve the branch pair when agency context exists
138
- const currentBranch = yield* git.getCurrentBranch(gitRoot)
139
- const branches = yield* resolveAgencyBranchPairWithAgencyJson(
140
- gitRoot,
141
- currentBranch,
142
- config.sourceBranchPattern,
143
- config.emitBranch,
144
- )
145
-
146
- // Build the gh pr command with the emit branch only when gh accepts a selector.
147
- const ghArgs =
148
- branches && shouldAppendEmitBranch(options.args)
149
- ? ["gh", "pr", ...options.args, branches.emitBranch]
150
- : ["gh", "pr", ...options.args]
151
-
152
- // Run gh pr with stdio inherited so output goes directly to terminal
153
- const exitCode = yield* Effect.tryPromise({
154
- try: async () => {
155
- const proc = Bun.spawn(ghArgs, {
156
- cwd: gitRoot,
157
- env: process.env,
158
- stdin: "inherit",
159
- stdout: "inherit",
160
- stderr: "inherit",
161
- })
162
- return proc.exited
163
- },
164
- catch: (error) =>
165
- new Error(
166
- `Failed to run gh pr: ${error instanceof Error ? error.message : String(error)}`,
167
- ),
168
- })
169
-
170
- if (exitCode !== 0) {
15
+ if (options.subcommand !== "create" || !options.taskId) {
171
16
  return yield* Effect.fail(
172
- new Error(`gh pr command exited with code ${exitCode}`),
17
+ new Error("Usage: agency pr create <task-id> [phase-id]"),
173
18
  )
174
19
  }
20
+ const pullRequests = yield* PullRequestService
21
+ const { log } = createLoggers(options)
22
+ const url = yield* pullRequests.create(
23
+ options.taskId,
24
+ options.phaseId,
25
+ options.draft,
26
+ options.cwd ?? process.cwd(),
27
+ )
28
+ log(options.json ? JSON.stringify({ url }, null, 2) : url)
175
29
  })
176
30
 
177
31
  export const help = `
178
- Usage: agency pr <subcommand> [flags]
179
-
180
- Wrapper for 'gh pr' that automatically uses the emitted branch in agency context.
181
-
182
- For gh pr subcommands that accept a PR selector, this command appends the
183
- emitted branch name when you do not provide a selector. Outside agency context,
184
- or when you provide a selector, it passes arguments through to 'gh pr' unchanged.
32
+ Usage: agency pr create <task-id> [phase-id]
185
33
 
186
- Examples:
187
- agency pr view --web # gh pr view --web <emit-branch>
188
- agency pr checks # gh pr checks <emit-branch>
189
- agency pr diff # gh pr diff <emit-branch>
34
+ Push the execution branch, create a GitHub pull request, and update its
35
+ task or phase document.
190
36
 
191
- Notes:
192
- - Requires gh CLI to be installed and authenticated
193
- - Uses source and emit patterns from ~/.config/agency/agency.json
194
- - Respects emitBranch field in agency.json when present
195
- - Falls through to gh pr unchanged outside agency context
37
+ Options:
38
+ --draft Create a draft pull request
39
+ --json Output the pull request URL as JSON
196
40
  `