@markjaquith/agency 2.54.4 → 2.56.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.
@@ -8,7 +8,12 @@ import {
8
8
  expandWorktreeCreateCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
- import type { RepositoryReference } from "../workbase/schemas"
11
+ import {
12
+ expandPostCheckoutCommand,
13
+ postCheckoutCommandEnvironment,
14
+ type CheckoutCommandVariables,
15
+ } from "../workbase/checkout-command"
16
+ import type { RepositoryReference, WorkbaseConfig } from "../workbase/schemas"
12
17
  import type { BaseCommandOptions } from "../utils/command"
13
18
  import { createLoggers } from "../utils/effect"
14
19
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -35,6 +40,7 @@ interface WorkspaceOperation {
35
40
  | "create-branch"
36
41
  | "create-worktree"
37
42
  | "create-workspace"
43
+ | "post-checkout"
38
44
  readonly repo: string
39
45
  readonly command: readonly string[]
40
46
  readonly status: "planned" | "completed"
@@ -176,6 +182,70 @@ const formatCommand = (args: readonly string[]) =>
176
182
  )
177
183
  .join(" ")
178
184
 
185
+ const runPostCheckoutHook = (options: {
186
+ readonly command: readonly string[] | undefined
187
+ readonly variables: CheckoutCommandVariables
188
+ readonly dryRun: boolean
189
+ readonly forwardOutput: boolean
190
+ readonly verboseLog: (...args: unknown[]) => void
191
+ readonly operations: WorkspaceOperation[]
192
+ }) =>
193
+ Effect.gen(function* () {
194
+ if (!options.command) return
195
+ let command: string[]
196
+ try {
197
+ command = expandPostCheckoutCommand(options.command, options.variables)
198
+ } catch (cause) {
199
+ return yield* new WorktreeError({
200
+ message:
201
+ cause instanceof Error
202
+ ? cause.message
203
+ : "Invalid postCheckoutCommand",
204
+ cause,
205
+ })
206
+ }
207
+ options.verboseLog(
208
+ `${options.dryRun ? "Planning" : "Running"} post-checkout command for '${options.variables.repoAlias}': ${formatCommand(command)}`,
209
+ )
210
+ if (options.dryRun) {
211
+ options.operations.push({
212
+ action: "post-checkout",
213
+ repo: options.variables.repoAlias,
214
+ command,
215
+ status: "planned",
216
+ })
217
+ return
218
+ }
219
+ const result = yield* FileSystemService.pipe(
220
+ Effect.flatMap((fs) =>
221
+ fs.runCommand(command, {
222
+ cwd: options.variables.checkoutPath,
223
+ captureOutput: true,
224
+ forwardOutput: options.forwardOutput,
225
+ env: postCheckoutCommandEnvironment(options.variables),
226
+ }),
227
+ ),
228
+ Effect.mapError(
229
+ (cause) =>
230
+ new WorktreeError({
231
+ message: `Failed to start post-checkout command for '${options.variables.repoAlias}': ${cause.message}`,
232
+ cause,
233
+ }),
234
+ ),
235
+ )
236
+ if (result.exitCode !== 0) {
237
+ return yield* new WorktreeError({
238
+ message: `Post-checkout command failed for '${options.variables.repoAlias}' with exit code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`,
239
+ })
240
+ }
241
+ options.operations.push({
242
+ action: "post-checkout",
243
+ repo: options.variables.repoAlias,
244
+ command,
245
+ status: "completed",
246
+ })
247
+ })
248
+
179
249
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
180
250
 
181
251
  const originRef = (ref: string) =>
@@ -812,6 +882,7 @@ const materializeJj = (options: {
812
882
  | { readonly repo: string; readonly branch: string }
813
883
  | RepositoryReference
814
884
  )[]
885
+ readonly config: WorkbaseConfig
815
886
  readonly commandOptions: MaterializeOptions
816
887
  }) =>
817
888
  Effect.gen(function* () {
@@ -826,6 +897,11 @@ const materializeJj = (options: {
826
897
  workspaceName: string
827
898
  }[] = []
828
899
  const base = "base" in options.execution ? options.execution.base : null
900
+ const { verboseLog } = createLoggers(options.commandOptions)
901
+ const forwardCommandOutput =
902
+ options.commandOptions.verbose === true &&
903
+ !options.commandOptions.silent &&
904
+ !options.commandOptions.json
829
905
 
830
906
  if (!options.commandOptions.dryRun)
831
907
  yield* fs.createDirectory(options.codePath)
@@ -920,7 +996,37 @@ const materializeJj = (options: {
920
996
  ...("branch" in checkout ? { branch: checkout.branch } : {}),
921
997
  })
922
998
  created.push({ repositoryPath, workspacePath, workspaceName })
999
+ const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1000
+ const registeredAfterCreate = (yield* backend.listWorkspaces(
1001
+ repositoryPath,
1002
+ )).find((workspace) => workspace.path === canonicalWorkspacePath)
1003
+ const head = yield* backend.workspaceHead(workspacePath)
1004
+ if (!registeredAfterCreate || head !== revision) {
1005
+ return yield* new WorktreeError({
1006
+ message: `Created jj workspace for '${checkout.repo}' failed validation`,
1007
+ })
1008
+ }
923
1009
  }
1010
+ yield* runPostCheckoutHook({
1011
+ command:
1012
+ options.config.repositories?.[checkout.repo]?.postCheckoutCommand,
1013
+ variables: {
1014
+ repoAlias: checkout.repo,
1015
+ repositoryPath,
1016
+ checkoutPath: workspacePath,
1017
+ checkoutKind: "branch" in checkout ? "writable" : "reference",
1018
+ requestedRef: requestedRevision,
1019
+ base: base ?? "",
1020
+ vcs: "jj",
1021
+ workbaseRoot: options.root,
1022
+ taskId: options.taskId,
1023
+ phaseId: options.phaseId ?? "",
1024
+ },
1025
+ dryRun: options.commandOptions.dryRun === true,
1026
+ forwardOutput: forwardCommandOutput,
1027
+ verboseLog,
1028
+ operations,
1029
+ })
924
1030
  reports.push({
925
1031
  repo: checkout.repo,
926
1032
  kind: "branch" in checkout ? "writable" : "reference",
@@ -957,16 +1063,36 @@ const materializeJj = (options: {
957
1063
  }).pipe(
958
1064
  Effect.catchAll((cause) =>
959
1065
  Effect.gen(function* () {
1066
+ const rolledBack: string[] = []
1067
+ const manualRecovery: string[] = []
960
1068
  for (const workspace of [...created].reverse()) {
961
- yield* backend
1069
+ const removed = yield* backend
962
1070
  .removeWorkspace({
963
1071
  repositoryPath: workspace.repositoryPath,
964
1072
  workspacePath: workspace.workspacePath,
965
1073
  workspaceName: workspace.workspaceName,
966
1074
  })
967
- .pipe(Effect.ignore)
1075
+ .pipe(
1076
+ Effect.as(true),
1077
+ Effect.catchAll(() => Effect.succeed(false)),
1078
+ )
1079
+ if (removed)
1080
+ rolledBack.push(`create-workspace ${workspace.workspaceName}`)
1081
+ else
1082
+ manualRecovery.push(
1083
+ `Remove jj workspace ${workspace.workspacePath}`,
1084
+ )
968
1085
  }
969
- return yield* Effect.fail(cause)
1086
+ return yield* new WorktreeError({
1087
+ message: `${cause instanceof Error ? cause.message : "Workspace creation failed"}. ${
1088
+ manualRecovery.length
1089
+ ? "Some effects require manual recovery"
1090
+ : "Created jj workspaces were rolled back"
1091
+ }`,
1092
+ rolledBack,
1093
+ manualRecovery,
1094
+ cause,
1095
+ })
970
1096
  }),
971
1097
  ),
972
1098
  )
@@ -1245,6 +1371,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1245
1371
  codePath,
1246
1372
  execution,
1247
1373
  requestedCheckouts,
1374
+ config,
1248
1375
  commandOptions: options,
1249
1376
  })
1250
1377
  }
@@ -1695,6 +1822,26 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1695
1822
  { captureOutput: true },
1696
1823
  )
1697
1824
  }
1825
+ yield* runPostCheckoutHook({
1826
+ command:
1827
+ config.repositories?.[alias]?.postCheckoutCommand,
1828
+ variables: {
1829
+ repoAlias: alias,
1830
+ repositoryPath,
1831
+ checkoutPath,
1832
+ checkoutKind: "writable",
1833
+ requestedRef: checkout.branch,
1834
+ base: executionBase,
1835
+ vcs: "git",
1836
+ workbaseRoot: root,
1837
+ taskId,
1838
+ phaseId: phaseId ?? "",
1839
+ },
1840
+ dryRun: true,
1841
+ forwardOutput: false,
1842
+ verboseLog,
1843
+ operations,
1844
+ })
1698
1845
  checkoutReports.push({
1699
1846
  repo: alias,
1700
1847
  kind: "writable",
@@ -1739,6 +1886,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1739
1886
  ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
1740
1887
  { captureOutput: true },
1741
1888
  )
1889
+ const currentBranch = yield* fs.runCommand(
1890
+ ["git", "-C", checkoutPath, "branch", "--show-current"],
1891
+ { captureOutput: true },
1892
+ )
1893
+ if (
1894
+ head.exitCode !== 0 ||
1895
+ currentBranch.exitCode !== 0 ||
1896
+ currentBranch.stdout.trim() !== checkout.branch
1897
+ ) {
1898
+ return yield* new WorktreeError({
1899
+ message: `Created worktree for '${alias}' failed validation for branch '${checkout.branch}'`,
1900
+ })
1901
+ }
1902
+ yield* runPostCheckoutHook({
1903
+ command: config.repositories?.[alias]?.postCheckoutCommand,
1904
+ variables: {
1905
+ repoAlias: alias,
1906
+ repositoryPath,
1907
+ checkoutPath,
1908
+ checkoutKind: "writable",
1909
+ requestedRef: checkout.branch,
1910
+ base: executionBase,
1911
+ vcs: "git",
1912
+ workbaseRoot: root,
1913
+ taskId,
1914
+ phaseId: phaseId ?? "",
1915
+ },
1916
+ dryRun: false,
1917
+ forwardOutput: forwardCommandOutput,
1918
+ verboseLog,
1919
+ operations,
1920
+ })
1742
1921
  checkoutReports.push({
1743
1922
  repo: alias,
1744
1923
  kind: "writable",
@@ -1831,6 +2010,26 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1831
2010
  command,
1832
2011
  status: "planned",
1833
2012
  })
2013
+ yield* runPostCheckoutHook({
2014
+ command:
2015
+ config.repositories?.[alias]?.postCheckoutCommand,
2016
+ variables: {
2017
+ repoAlias: alias,
2018
+ repositoryPath,
2019
+ checkoutPath,
2020
+ checkoutKind: "reference",
2021
+ requestedRef: checkout.ref,
2022
+ base: executionBase,
2023
+ vcs: "git",
2024
+ workbaseRoot: root,
2025
+ taskId,
2026
+ phaseId: phaseId ?? "",
2027
+ },
2028
+ dryRun: true,
2029
+ forwardOutput: false,
2030
+ verboseLog,
2031
+ operations,
2032
+ })
1834
2033
  checkoutReports.push({
1835
2034
  repo: alias,
1836
2035
  kind: "reference",
@@ -1855,6 +2054,34 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1855
2054
  command,
1856
2055
  status: "completed",
1857
2056
  })
2057
+ const head = yield* fs.runCommand(
2058
+ ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
2059
+ { captureOutput: true },
2060
+ )
2061
+ if (head.exitCode !== 0 || head.stdout.trim() !== commit) {
2062
+ return yield* new WorktreeError({
2063
+ message: `Created reference checkout for '${alias}' failed validation for '${checkout.ref}'`,
2064
+ })
2065
+ }
2066
+ yield* runPostCheckoutHook({
2067
+ command: config.repositories?.[alias]?.postCheckoutCommand,
2068
+ variables: {
2069
+ repoAlias: alias,
2070
+ repositoryPath,
2071
+ checkoutPath,
2072
+ checkoutKind: "reference",
2073
+ requestedRef: checkout.ref,
2074
+ base: executionBase,
2075
+ vcs: "git",
2076
+ workbaseRoot: root,
2077
+ taskId,
2078
+ phaseId: phaseId ?? "",
2079
+ },
2080
+ dryRun: false,
2081
+ forwardOutput: forwardCommandOutput,
2082
+ verboseLog,
2083
+ operations,
2084
+ })
1858
2085
  checkoutReports.push({
1859
2086
  repo: alias,
1860
2087
  kind: "reference",
@@ -1918,6 +2145,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1918
2145
  join(root, "repos", checkout.repo),
1919
2146
  "worktree",
1920
2147
  "remove",
2148
+ "--force",
1921
2149
  checkoutPath,
1922
2150
  ],
1923
2151
  { captureOutput: true },
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 { PushService } from "./services/PushService"
14
15
  import { ArchiveService } from "./services/ArchiveService"
15
16
  import { IntegrationService } from "./services/IntegrationService"
16
17
  import { ContextService } from "./services/ContextService"
@@ -46,6 +47,7 @@ const TestLayer = Layer.mergeAll(
46
47
  PhaseService.Default,
47
48
  WorktreeService.Default,
48
49
  PullRequestService.Default,
50
+ PushService.Default,
49
51
  ArchiveService.Default,
50
52
  IntegrationService.Default,
51
53
  ContextService.Default,
@@ -69,6 +69,11 @@ change only the writable checkout, keep durable decisions current, and run the
69
69
  repository's formatting, type checks, build, dead-code checks, and focused tests.
70
70
  Review and commit the diff according to the repository's instructions.
71
71
 
72
+ Use `agency push` from an execution task or phase to validate and publish its
73
+ declared delivery branch or bookmark without creating a pull request. The
74
+ command never authors semantic commit descriptions; resolve its reported change
75
+ IDs and remediation commands before retrying.
76
+
72
77
  `agency work` is the human launch flow: it reconciles managed integration,
73
78
  selects work, checks readiness, prepares checkouts, marks execution work
74
79
  `working` without creating a claim, and starts the runner. Epic and multi-phase
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ expandPostCheckoutCommand,
4
+ postCheckoutCommandEnvironment,
5
+ } from "./checkout-command"
6
+
7
+ const variables = {
8
+ repoAlias: "app",
9
+ repositoryPath: "/work/repos/app",
10
+ checkoutPath: "/work/tasks/example/code/app",
11
+ checkoutKind: "reference" as const,
12
+ requestedRef: "main",
13
+ base: "",
14
+ vcs: "jj" as const,
15
+ workbaseRoot: "/work",
16
+ taskId: "example",
17
+ phaseId: "",
18
+ }
19
+
20
+ describe("post-checkout command templates", () => {
21
+ test("expands all context placeholders without shell interpolation", () => {
22
+ expect(
23
+ expandPostCheckoutCommand(
24
+ [
25
+ "tool",
26
+ "{repoAlias}",
27
+ "{repositoryPath}",
28
+ "{checkoutPath}",
29
+ "{checkoutKind}",
30
+ "{requestedRef}",
31
+ "{base}",
32
+ "{vcs}",
33
+ "{workbaseRoot}",
34
+ "{taskId}",
35
+ "{phaseId}",
36
+ ],
37
+ variables,
38
+ ),
39
+ ).toEqual(["tool", ...Object.values(variables)])
40
+ })
41
+
42
+ test("rejects unknown placeholders", () => {
43
+ expect(() =>
44
+ expandPostCheckoutCommand(["tool", "{unknown}"], variables),
45
+ ).toThrow("{unknown}")
46
+ })
47
+
48
+ test("provides matching environment variables with empty optional values", () => {
49
+ expect(postCheckoutCommandEnvironment(variables)).toEqual({
50
+ AGENCY_REPO_ALIAS: variables.repoAlias,
51
+ AGENCY_REPOSITORY_PATH: variables.repositoryPath,
52
+ AGENCY_CHECKOUT_PATH: variables.checkoutPath,
53
+ AGENCY_CHECKOUT_KIND: variables.checkoutKind,
54
+ AGENCY_REQUESTED_REF: variables.requestedRef,
55
+ AGENCY_BASE: "",
56
+ AGENCY_VCS: variables.vcs,
57
+ AGENCY_WORKBASE_ROOT: variables.workbaseRoot,
58
+ AGENCY_TASK_ID: variables.taskId,
59
+ AGENCY_PHASE_ID: "",
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,66 @@
1
+ export interface CheckoutCommandVariables {
2
+ readonly repoAlias: string
3
+ readonly repositoryPath: string
4
+ readonly checkoutPath: string
5
+ readonly checkoutKind: "writable" | "reference"
6
+ readonly requestedRef: string
7
+ readonly base: string
8
+ readonly vcs: "git" | "jj"
9
+ readonly workbaseRoot: string
10
+ readonly taskId: string
11
+ readonly phaseId: string
12
+ }
13
+
14
+ const PLACEHOLDERS = new Set<keyof CheckoutCommandVariables>([
15
+ "repoAlias",
16
+ "repositoryPath",
17
+ "checkoutPath",
18
+ "checkoutKind",
19
+ "requestedRef",
20
+ "base",
21
+ "vcs",
22
+ "workbaseRoot",
23
+ "taskId",
24
+ "phaseId",
25
+ ])
26
+
27
+ export const validatePostCheckoutCommand = (command: readonly string[]) => {
28
+ for (const argument of command) {
29
+ for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
30
+ const placeholder = match[1]!
31
+ if (!PLACEHOLDERS.has(placeholder as keyof CheckoutCommandVariables)) {
32
+ throw new Error(
33
+ `Unknown postCheckoutCommand placeholder: {${placeholder}}`,
34
+ )
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ export const expandPostCheckoutCommand = (
41
+ command: readonly string[],
42
+ variables: CheckoutCommandVariables,
43
+ ): string[] => {
44
+ validatePostCheckoutCommand(command)
45
+
46
+ return command.map((argument) =>
47
+ argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
48
+ return variables[placeholder as keyof CheckoutCommandVariables] ?? match
49
+ }),
50
+ )
51
+ }
52
+
53
+ export const postCheckoutCommandEnvironment = (
54
+ variables: CheckoutCommandVariables,
55
+ ): Record<string, string> => ({
56
+ AGENCY_REPO_ALIAS: variables.repoAlias,
57
+ AGENCY_REPOSITORY_PATH: variables.repositoryPath,
58
+ AGENCY_CHECKOUT_PATH: variables.checkoutPath,
59
+ AGENCY_CHECKOUT_KIND: variables.checkoutKind,
60
+ AGENCY_REQUESTED_REF: variables.requestedRef,
61
+ AGENCY_BASE: variables.base,
62
+ AGENCY_VCS: variables.vcs,
63
+ AGENCY_WORKBASE_ROOT: variables.workbaseRoot,
64
+ AGENCY_TASK_ID: variables.taskId,
65
+ AGENCY_PHASE_ID: variables.phaseId,
66
+ })
@@ -187,6 +187,40 @@ describe("body-of-work descriptions", () => {
187
187
  })
188
188
  })
189
189
 
190
+ describe("repository post-checkout configuration", () => {
191
+ test("accepts a per-repository argv command", () => {
192
+ const config = Schema.decodeUnknownSync(WorkbaseConfig)({
193
+ version: 2,
194
+ repositories: {
195
+ agency: {
196
+ remote: "https://example.com/agency.git",
197
+ postCheckoutCommand: ["bun", "install", "--frozen-lockfile"],
198
+ },
199
+ },
200
+ })
201
+
202
+ expect(config.repositories?.agency?.postCheckoutCommand).toEqual([
203
+ "bun",
204
+ "install",
205
+ "--frozen-lockfile",
206
+ ])
207
+ })
208
+
209
+ test("rejects shell strings in place of argv arrays", () => {
210
+ expect(() =>
211
+ Schema.decodeUnknownSync(WorkbaseConfig)({
212
+ version: 2,
213
+ repositories: {
214
+ agency: {
215
+ remote: "https://example.com/agency.git",
216
+ postCheckoutCommand: "bun install",
217
+ },
218
+ },
219
+ }),
220
+ ).toThrow()
221
+ })
222
+ })
223
+
190
224
  describe("runner configuration", () => {
191
225
  test("accepts named argv commands with resume commands and environment", () => {
192
226
  const config = Schema.decodeUnknownSync(WorkbaseConfig)({
@@ -24,6 +24,7 @@ export const RepositoryRemote = NonEmptyString.pipe(
24
24
 
25
25
  export const RepositoryDeclaration = Schema.Struct({
26
26
  remote: RepositoryRemote,
27
+ postCheckoutCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
27
28
  })
28
29
 
29
30
  export const RepositoryReference = Schema.Struct({