@markjaquith/agency 2.55.0 → 2.56.1

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 CHANGED
@@ -235,6 +235,56 @@ Supplemental read-only repositories remain detached Git worktrees at their
235
235
  declared refs so they do not acquire writable branches. Jj workbases always use
236
236
  jj workspaces and ignore this Git-specific customization.
237
237
 
238
+ ### Post-checkout Commands
239
+
240
+ Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
241
+ template for repository-specific setup. Agency invokes it directly, without a
242
+ shell, with the new checkout as its working directory:
243
+
244
+ ```json
245
+ {
246
+ "version": 2,
247
+ "repositories": {
248
+ "frontend": {
249
+ "remote": "git@example.com:team/frontend.git",
250
+ "postCheckoutCommand": ["bun", "install", "--frozen-lockfile"]
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ The hook runs for each newly created managed checkout, including writable and
257
+ reference checkouts, after Git worktree or jj workspace creation has completed
258
+ and Agency has validated the checkout. It does not run for a reused checkout or
259
+ for inspection-only commands. A custom `worktreeCreateCommand` completes and is
260
+ validated before this hook runs.
261
+
262
+ Available placeholders and matching environment variables are:
263
+
264
+ | Placeholder | Environment | Value |
265
+ | ------------------ | ------------------------ | ---------------------------------------------- |
266
+ | `{repoAlias}` | `AGENCY_REPO_ALIAS` | Repository alias |
267
+ | `{repositoryPath}` | `AGENCY_REPOSITORY_PATH` | Absolute source repository path under `repos/` |
268
+ | `{checkoutPath}` | `AGENCY_CHECKOUT_PATH` | Absolute managed checkout path |
269
+ | `{checkoutKind}` | `AGENCY_CHECKOUT_KIND` | `writable` or `reference` |
270
+ | `{requestedRef}` | `AGENCY_REQUESTED_REF` | Requested branch, reference, or review commit |
271
+ | `{base}` | `AGENCY_BASE` | Configured execution base |
272
+ | `{vcs}` | `AGENCY_VCS` | `git` or `jj` |
273
+ | `{workbaseRoot}` | `AGENCY_WORKBASE_ROOT` | Absolute workbase root |
274
+ | `{taskId}` | `AGENCY_TASK_ID` | Task ID |
275
+ | `{phaseId}` | `AGENCY_PHASE_ID` | Phase ID |
276
+
277
+ `{base}` and `{phaseId}` and their environment variables are empty strings when
278
+ they do not apply. Dry runs report a planned `post-checkout` operation but never
279
+ execute it. Verbose output identifies the repository and expanded command.
280
+
281
+ Hook success is part of checkout creation. A non-zero exit or failure to start
282
+ rolls back the checkout and any branch created by the same operation; if cleanup
283
+ also fails, Agency reports the exact manual recovery action. A later command
284
+ retries checkout creation and the hook rather than reusing an uninitialized
285
+ checkout. Hook commands should be idempotent so a retry is safe after any
286
+ external effects the failed invocation may have completed.
287
+
238
288
  ### Agent Runners
239
289
 
240
290
  OpenCode and Claude Code are built-in runner presets. Select either preset or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.55.0",
3
+ "version": "2.56.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -58,6 +58,7 @@
58
58
  "tag": "latest"
59
59
  },
60
60
  "scripts": {
61
+ "benchmark:sync": "bun scripts/benchmark-sync.ts",
61
62
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
62
63
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
63
64
  "format": "oxfmt",
@@ -9,6 +9,7 @@ import {
9
9
  createTempDir,
10
10
  runTestEffect,
11
11
  } from "../test-utils"
12
+ import type { Progress } from "../utils/progress"
12
13
  import { sync } from "./sync"
13
14
 
14
15
  const git = async (args: string[], cwd: string) => {
@@ -97,4 +98,30 @@ describe("sync command", () => {
97
98
  ],
98
99
  })
99
100
  })
101
+
102
+ test("reports human-readable progress without polluting JSON output", async () => {
103
+ const updates: string[] = []
104
+ const progress: Progress = {
105
+ start: (message) => updates.push(`start:${message}`),
106
+ succeed: (message) => updates.push(`succeed:${message}`),
107
+ fail: (message) => updates.push(`fail:${message}`),
108
+ }
109
+
110
+ await captureLogs(() =>
111
+ runTestEffect(sync({ cwd: root, silent: false }, progress)),
112
+ )
113
+ expect(updates).toEqual([
114
+ "start:Validating workbase",
115
+ "start:Inspected 1 repositories",
116
+ "start:Queried pull requests 1/1 (task:example)",
117
+ "start:Reconciled execution units 1/1 (task:example)",
118
+ "succeed:Synchronized 1 execution units",
119
+ ])
120
+
121
+ updates.length = 0
122
+ await captureLogs(() =>
123
+ runTestEffect(sync({ cwd: root, dryRun: true, json: true }, progress)),
124
+ )
125
+ expect(updates).toEqual([])
126
+ })
100
127
  })
@@ -2,19 +2,54 @@ import { Effect } from "effect"
2
2
  import { SyncService } from "../services/SyncService"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { createProgress, type Progress } from "../utils/progress"
5
6
 
6
7
  interface SyncCommandOptions extends BaseCommandOptions {
7
8
  readonly dryRun?: boolean
8
9
  }
9
10
 
10
- export const sync = (options: SyncCommandOptions = {}) =>
11
+ export const sync = (
12
+ options: SyncCommandOptions = {},
13
+ progress: Progress = createProgress({
14
+ silent: options.silent || options.json,
15
+ }),
16
+ ) =>
11
17
  Effect.gen(function* () {
12
18
  const service = yield* SyncService
13
19
  const { log } = createLoggers(options)
14
- const result = yield* service.reconcile({
15
- cwd: options.cwd,
16
- apply: options.dryRun !== true,
17
- })
20
+ const showProgress = !options.silent && !options.json
21
+ if (showProgress) progress.start("Validating workbase")
22
+ const result = yield* service
23
+ .reconcile({
24
+ cwd: options.cwd,
25
+ apply: options.dryRun !== true,
26
+ onProgress: showProgress
27
+ ? ({ stage, current, total, target }) => {
28
+ if (stage === "repositories") {
29
+ progress.start(`Inspected ${total} repositories`)
30
+ } else if (stage === "pull-requests") {
31
+ progress.start(
32
+ `Queried pull requests ${current}/${total}${target ? ` (${target})` : ""}`,
33
+ )
34
+ } else {
35
+ progress.start(
36
+ `Reconciled execution units ${current}/${total}${target ? ` (${target})` : ""}`,
37
+ )
38
+ }
39
+ }
40
+ : undefined,
41
+ })
42
+ .pipe(
43
+ Effect.tapError(() =>
44
+ Effect.sync(() => {
45
+ if (showProgress) progress.fail("Workbase sync failed")
46
+ }),
47
+ ),
48
+ )
49
+ if (showProgress)
50
+ progress.succeed(
51
+ `Synchronized ${result.executions.length} execution units`,
52
+ )
18
53
  if (options.json) return log(JSON.stringify(result, null, 2))
19
54
 
20
55
  for (const action of result.repositories.actions) {
@@ -173,6 +173,18 @@ export class DoctorService extends Effect.Service<DoctorService>()(
173
173
  ] as const,
174
174
  ]
175
175
  : []),
176
+ ...Object.entries(config.repositories ?? {}).flatMap(
177
+ ([alias, repository]) =>
178
+ repository.postCheckoutCommand
179
+ ? [
180
+ [
181
+ `integration.repository.${alias}.post-checkout`,
182
+ repository.postCheckoutCommand,
183
+ `Repository '${alias}' post-checkout hook`,
184
+ ] as const,
185
+ ]
186
+ : [],
187
+ ),
176
188
  ...Object.entries(config.runners ?? {}).map(
177
189
  ([name, runner]) =>
178
190
  [
@@ -796,4 +796,119 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
796
796
  ),
797
797
  ).toMatchObject({ valid: true, issues: [] })
798
798
  })
799
+
800
+ test("reads each repository workspace inventory once", async () => {
801
+ for (const id of ["first", "second"]) {
802
+ await runTestEffect(
803
+ TaskService.pipe(
804
+ Effect.flatMap((service) =>
805
+ service.create(
806
+ {
807
+ id,
808
+ ticketUrl: null,
809
+ repo: "agency",
810
+ branch: `feat/${id}`,
811
+ base: "main",
812
+ },
813
+ root,
814
+ ),
815
+ ),
816
+ ),
817
+ )
818
+ await runTestEffect(
819
+ WorktreeService.pipe(
820
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
821
+ ),
822
+ )
823
+ await runTestEffect(
824
+ TaskService.pipe(
825
+ Effect.flatMap((service) =>
826
+ service.setStatus(id, "done", root, {
827
+ summary: `Completed ${id}`,
828
+ }),
829
+ ),
830
+ ),
831
+ )
832
+ }
833
+
834
+ const callsPath = join(root, "workspace-list-calls")
835
+ const realGit = Bun.which("git")!
836
+ const gitWrapper = join(root, "bin", "git")
837
+ await Bun.write(
838
+ gitWrapper,
839
+ `#!/bin/sh
840
+ case "$*" in
841
+ *"worktree list --porcelain -z"*) printf 'call\\n' >> ${JSON.stringify(callsPath)} ;;
842
+ esac
843
+ exec ${JSON.stringify(realGit)} "$@"
844
+ `,
845
+ )
846
+ await chmod(gitWrapper, 0o755)
847
+
848
+ await runTestEffect(
849
+ SyncService.pipe(
850
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
851
+ ),
852
+ )
853
+ expect((await Bun.file(callsPath).text()).trim().split("\n")).toHaveLength(
854
+ 1,
855
+ )
856
+ })
857
+
858
+ test("queries pull request providers concurrently", async () => {
859
+ for (const id of ["first", "second", "third"]) {
860
+ await runTestEffect(
861
+ TaskService.pipe(
862
+ Effect.flatMap((service) =>
863
+ service.create(
864
+ {
865
+ id,
866
+ ticketUrl: null,
867
+ repo: "agency",
868
+ branch: `feat/${id}`,
869
+ base: "main",
870
+ },
871
+ root,
872
+ ),
873
+ ),
874
+ ),
875
+ )
876
+ }
877
+
878
+ const barrier = join(root, "query-barrier")
879
+ await mkdir(barrier)
880
+ await Bun.write(
881
+ join(root, "bin", "gh"),
882
+ `#!/bin/sh
883
+ branch=""
884
+ while [ "$#" -gt 0 ]; do
885
+ if [ "$1" = "--head" ]; then branch="$2"; break; fi
886
+ shift
887
+ done
888
+ id="\${branch##*/}"
889
+ touch ${JSON.stringify(barrier)}/"\${id}"
890
+ attempt=0
891
+ while [ "$attempt" -lt 200 ]; do
892
+ set -- ${JSON.stringify(barrier)}/*
893
+ if [ -e "$1" ] && [ "$#" -ge 3 ]; then printf '[]\\n'; exit 0; fi
894
+ attempt=$((attempt + 1))
895
+ sleep 0.01
896
+ done
897
+ echo "provider queries were serialized" >&2
898
+ exit 9
899
+ `,
900
+ )
901
+ await chmod(join(root, "bin", "gh"), 0o755)
902
+
903
+ const result = await runTestEffect(
904
+ SyncService.pipe(
905
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
906
+ ),
907
+ )
908
+ expect(
909
+ result.warnings.filter(
910
+ (warning) => warning.kind === "pr-discovery-unavailable",
911
+ ),
912
+ ).toEqual([])
913
+ })
799
914
  })