@markjaquith/agency 2.54.3 → 2.55.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.
@@ -0,0 +1,576 @@
1
+ import { Data, Effect } from "effect"
2
+ import { ContextService } from "./ContextService"
3
+ import { FileSystemService } from "./FileSystemService"
4
+ import { PhaseService } from "./PhaseService"
5
+ import { TaskService } from "./TaskService"
6
+ import { WorkbaseService } from "./WorkbaseService"
7
+
8
+ class PushError extends Data.TaggedError("PushError")<{
9
+ readonly message: string
10
+ }> {}
11
+
12
+ interface CommandResult {
13
+ readonly exitCode: number
14
+ readonly stdout: string
15
+ readonly stderr: string
16
+ }
17
+
18
+ interface CommitMetadata {
19
+ readonly commitId: string
20
+ readonly changeId?: string
21
+ readonly description: string
22
+ readonly empty: boolean
23
+ readonly authorName: string
24
+ readonly authorEmail: string
25
+ readonly conflict: boolean
26
+ readonly parents: readonly string[]
27
+ }
28
+
29
+ interface PushResult {
30
+ readonly vcs: "git" | "jj"
31
+ readonly taskId: string
32
+ readonly phaseId?: string
33
+ readonly branch: string
34
+ readonly base: string
35
+ readonly remote: string
36
+ readonly tip: string
37
+ readonly changeId?: string
38
+ }
39
+
40
+ const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
41
+
42
+ const requireCommand = (
43
+ fs: FileSystemService,
44
+ args: readonly string[],
45
+ cwd: string,
46
+ label: string,
47
+ ) =>
48
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
49
+ Effect.flatMap((result) =>
50
+ result.exitCode === 0
51
+ ? Effect.succeed(result)
52
+ : Effect.fail(
53
+ new PushError({
54
+ message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
55
+ }),
56
+ ),
57
+ ),
58
+ )
59
+
60
+ const git = (
61
+ fs: FileSystemService,
62
+ cwd: string,
63
+ args: readonly string[],
64
+ label: string,
65
+ ) => requireCommand(fs, ["git", ...args], cwd, label)
66
+
67
+ const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
68
+ fs
69
+ .runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
70
+ cwd,
71
+ captureOutput: true,
72
+ })
73
+ .pipe(
74
+ Effect.map((result) =>
75
+ result.exitCode === 0 ? result.stdout.trim() || null : null,
76
+ ),
77
+ )
78
+
79
+ const gitAncestor = (
80
+ fs: FileSystemService,
81
+ cwd: string,
82
+ ancestor: string,
83
+ descendant: string,
84
+ ) =>
85
+ fs
86
+ .runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
87
+ cwd,
88
+ captureOutput: true,
89
+ })
90
+ .pipe(
91
+ Effect.flatMap((result) => {
92
+ if (result.exitCode === 0) return Effect.succeed(true)
93
+ if (result.exitCode === 1) return Effect.succeed(false)
94
+ return Effect.fail(
95
+ new PushError({
96
+ message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
97
+ }),
98
+ )
99
+ }),
100
+ )
101
+
102
+ const parseGitCommits = (output: string): readonly CommitMetadata[] =>
103
+ output
104
+ .split("\x1e")
105
+ .map((record) => record.replace(/^\n+|\n+$/g, ""))
106
+ .filter(Boolean)
107
+ .map((record) => {
108
+ const [
109
+ commitId = "",
110
+ authorName = "",
111
+ authorEmail = "",
112
+ description = "",
113
+ ] = record.split("\0")
114
+ return {
115
+ commitId,
116
+ description,
117
+ empty: false,
118
+ authorName,
119
+ authorEmail,
120
+ conflict: false,
121
+ parents: [],
122
+ }
123
+ })
124
+
125
+ const validateGitCommits = (
126
+ commits: readonly CommitMetadata[],
127
+ base: string,
128
+ ) => {
129
+ if (commits.length === 0) {
130
+ throw new PushError({
131
+ message: `No commits to publish after base '${base}'`,
132
+ })
133
+ }
134
+ const issues: string[] = []
135
+ for (const commit of commits) {
136
+ if (!commit.description.trim()) {
137
+ issues.push(
138
+ `Commit ${commit.commitId} has an empty message. Run: git rebase -i ${base}`,
139
+ )
140
+ }
141
+ if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
142
+ issues.push(
143
+ `Commit ${commit.commitId} has an invalid author. Run: git rebase -i ${base}`,
144
+ )
145
+ }
146
+ }
147
+ if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
148
+ }
149
+
150
+ const publishGit = (
151
+ fs: FileSystemService,
152
+ checkout: string,
153
+ remote: string,
154
+ branch: string,
155
+ base: string,
156
+ ) =>
157
+ Effect.gen(function* () {
158
+ const currentBranch = yield* git(
159
+ fs,
160
+ checkout,
161
+ ["symbolic-ref", "--quiet", "--short", "HEAD"],
162
+ "Git checkout must be attached to the declared branch",
163
+ )
164
+ if (currentBranch.stdout.trim() !== branch) {
165
+ return yield* new PushError({
166
+ message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
167
+ })
168
+ }
169
+ const status = yield* git(
170
+ fs,
171
+ checkout,
172
+ ["status", "--porcelain=v1"],
173
+ "Failed to inspect Git status",
174
+ )
175
+ if (status.stdout.length > 0) {
176
+ return yield* new PushError({
177
+ message:
178
+ "Cannot publish a dirty Git worktree; commit or discard changes first",
179
+ })
180
+ }
181
+
182
+ yield* git(
183
+ fs,
184
+ checkout,
185
+ ["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
186
+ `Failed to fetch remote '${remote}'`,
187
+ )
188
+ const tip = yield* gitRevision(fs, checkout, "HEAD")
189
+ const baseRevision = yield* gitRevision(
190
+ fs,
191
+ checkout,
192
+ `refs/remotes/${remote}/${base}`,
193
+ )
194
+ if (!tip || !baseRevision) {
195
+ return yield* new PushError({
196
+ message: `Declared base '${base}' was not found on remote '${remote}'`,
197
+ })
198
+ }
199
+ if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
200
+ return yield* new PushError({
201
+ message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
202
+ })
203
+ }
204
+
205
+ const log = yield* git(
206
+ fs,
207
+ checkout,
208
+ [
209
+ "log",
210
+ "--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
211
+ `${baseRevision}..${tip}`,
212
+ ],
213
+ "Failed to inspect outgoing Git commits",
214
+ )
215
+ yield* Effect.try({
216
+ try: () => validateGitCommits(parseGitCommits(log.stdout), base),
217
+ catch: (cause) => cause as PushError,
218
+ })
219
+
220
+ const remoteTip = yield* gitRevision(
221
+ fs,
222
+ checkout,
223
+ `refs/remotes/${remote}/${branch}`,
224
+ )
225
+ if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
226
+ return yield* new PushError({
227
+ message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
228
+ })
229
+ }
230
+
231
+ yield* git(
232
+ fs,
233
+ checkout,
234
+ ["push", remote, `HEAD:refs/heads/${branch}`],
235
+ `Failed to push declared branch '${branch}'`,
236
+ )
237
+ yield* git(
238
+ fs,
239
+ checkout,
240
+ [
241
+ "config",
242
+ `remote.${remote}.fetch`,
243
+ `+refs/heads/*:refs/remotes/${remote}/*`,
244
+ ],
245
+ `Failed to configure remote '${remote}' tracking`,
246
+ )
247
+ yield* git(
248
+ fs,
249
+ checkout,
250
+ [
251
+ "fetch",
252
+ remote,
253
+ `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
254
+ ],
255
+ `Failed to refresh published branch '${branch}'`,
256
+ )
257
+ yield* git(
258
+ fs,
259
+ checkout,
260
+ ["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
261
+ `Failed to establish upstream tracking for branch '${branch}'`,
262
+ )
263
+ return { tip }
264
+ })
265
+
266
+ const jjTemplate =
267
+ '"{\\"commitId\\":" ++ json(commit_id) ++ ",\\"changeId\\":" ++ json(change_id) ++ ",\\"description\\":" ++ json(description) ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"authorName\\":" ++ json(author.name()) ++ ",\\"authorEmail\\":" ++ json(author.email()) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"parents\\":" ++ json(parents.map(|parent| parent.commit_id())) ++ "}\\n"'
268
+
269
+ const jjExact = (value: string) =>
270
+ value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
271
+
272
+ const jj = (
273
+ fs: FileSystemService,
274
+ cwd: string,
275
+ args: readonly string[],
276
+ label: string,
277
+ ) => requireCommand(fs, ["jj", "--no-pager", ...args], cwd, label)
278
+
279
+ const jjCommits = (fs: FileSystemService, cwd: string, revision: string) =>
280
+ jj(
281
+ fs,
282
+ cwd,
283
+ ["log", "--no-graph", "-r", revision, "-T", jjTemplate],
284
+ "Failed to inspect jj changes",
285
+ ).pipe(
286
+ Effect.map((result) =>
287
+ result.stdout
288
+ .split("\n")
289
+ .filter(Boolean)
290
+ .map((line) => JSON.parse(line) as CommitMetadata),
291
+ ),
292
+ )
293
+
294
+ const jjRevision = (fs: FileSystemService, cwd: string, revision: string) =>
295
+ jjCommits(fs, cwd, revision).pipe(
296
+ Effect.map((commits) => {
297
+ if (commits.length === 0) return null
298
+ if (commits.length === 1) return commits[0]!
299
+ throw new PushError({
300
+ message: `Revision '${revision}' resolves to multiple jj commits`,
301
+ })
302
+ }),
303
+ )
304
+
305
+ const optionalJjRevision = (
306
+ fs: FileSystemService,
307
+ cwd: string,
308
+ revision: string,
309
+ ) =>
310
+ jjCommits(fs, cwd, revision).pipe(
311
+ Effect.catchTag("PushError", () => Effect.succeed([])),
312
+ Effect.map((commits) => {
313
+ if (commits.length === 0) return null
314
+ if (commits.length === 1) return commits[0]!
315
+ throw new PushError({
316
+ message: `Bookmark '${revision}' is conflicted or resolves to multiple commits`,
317
+ })
318
+ }),
319
+ )
320
+
321
+ const jjAncestor = (
322
+ fs: FileSystemService,
323
+ cwd: string,
324
+ ancestor: string,
325
+ descendant: string,
326
+ ) =>
327
+ jjCommits(fs, cwd, `${ancestor} & ::${descendant}`).pipe(
328
+ Effect.map((commits) => commits.length === 1),
329
+ )
330
+
331
+ const validateJjCommits = (
332
+ commits: readonly CommitMetadata[],
333
+ base: string,
334
+ ) => {
335
+ if (commits.length === 0) {
336
+ throw new PushError({
337
+ message: `No changes to publish after base '${base}'`,
338
+ })
339
+ }
340
+ const issues: string[] = []
341
+ for (const commit of commits) {
342
+ const id = commit.changeId!
343
+ if (commit.conflict) {
344
+ issues.push(`Change ${id} contains conflicts. Run: jj resolve -r ${id}`)
345
+ }
346
+ if (!commit.description.trim()) {
347
+ issues.push(`Change ${id} has no description. Run: jj describe -r ${id}`)
348
+ }
349
+ if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
350
+ issues.push(
351
+ `Change ${id} has an invalid author. Run: jj metaedit -r ${id} --author 'Name <email>'`,
352
+ )
353
+ }
354
+ }
355
+ if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
356
+ }
357
+
358
+ const publishJj = (
359
+ fs: FileSystemService,
360
+ checkout: string,
361
+ remote: string,
362
+ branch: string,
363
+ base: string,
364
+ ) =>
365
+ Effect.gen(function* () {
366
+ yield* jj(
367
+ fs,
368
+ checkout,
369
+ ["git", "fetch", "--remote", remote],
370
+ `Failed to fetch remote '${remote}'`,
371
+ )
372
+ const workingCopy = yield* jjRevision(fs, checkout, "@")
373
+ if (!workingCopy) {
374
+ return yield* new PushError({
375
+ message: "jj working copy commit was not found",
376
+ })
377
+ }
378
+ const canonicalPostCommit =
379
+ workingCopy.empty &&
380
+ !workingCopy.description.trim() &&
381
+ workingCopy.parents.length === 1
382
+ const tip = canonicalPostCommit
383
+ ? yield* jjRevision(fs, checkout, "@-")
384
+ : workingCopy
385
+ if (!tip) {
386
+ return yield* new PushError({
387
+ message: "jj publication tip was not found",
388
+ })
389
+ }
390
+
391
+ const baseBookmark = `${base}@${remote}`
392
+ const baseRevision = yield* optionalJjRevision(
393
+ fs,
394
+ checkout,
395
+ `remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
396
+ )
397
+ if (!baseRevision) {
398
+ return yield* new PushError({
399
+ message: `Declared base '${base}' was not found on remote '${remote}'`,
400
+ })
401
+ }
402
+ if (
403
+ !(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
404
+ ) {
405
+ return yield* new PushError({
406
+ message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
407
+ })
408
+ }
409
+
410
+ const outgoing = yield* jjCommits(
411
+ fs,
412
+ checkout,
413
+ `${baseRevision.commitId}..${tip.commitId}`,
414
+ )
415
+ yield* Effect.try({
416
+ try: () => validateJjCommits(outgoing, base),
417
+ catch: (cause) => cause as PushError,
418
+ })
419
+
420
+ const localBookmark = yield* optionalJjRevision(
421
+ fs,
422
+ checkout,
423
+ `bookmarks(exact:"${jjExact(branch)}")`,
424
+ )
425
+ if (
426
+ localBookmark &&
427
+ !(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
428
+ ) {
429
+ return yield* new PushError({
430
+ message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
431
+ })
432
+ }
433
+ const remoteBookmark = yield* optionalJjRevision(
434
+ fs,
435
+ checkout,
436
+ `remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
437
+ )
438
+ if (
439
+ remoteBookmark &&
440
+ !(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
441
+ ) {
442
+ return yield* new PushError({
443
+ message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
444
+ })
445
+ }
446
+
447
+ yield* jj(
448
+ fs,
449
+ checkout,
450
+ ["bookmark", "set", branch, "-r", tip.commitId],
451
+ `Failed to set declared bookmark '${branch}'`,
452
+ )
453
+ yield* jj(
454
+ fs,
455
+ checkout,
456
+ ["git", "push", "--remote", remote, "--bookmark", branch],
457
+ `Failed to push declared bookmark '${branch}'`,
458
+ )
459
+ yield* jj(
460
+ fs,
461
+ checkout,
462
+ ["bookmark", "track", `${branch}@${remote}`],
463
+ `Failed to track remote bookmark '${branch}@${remote}'`,
464
+ )
465
+ return { tip: tip.commitId, changeId: tip.changeId }
466
+ })
467
+
468
+ export class PushService extends Effect.Service<PushService>()("PushService", {
469
+ sync: () => ({
470
+ publish: (startPath: string = process.cwd()) =>
471
+ Effect.gen(function* () {
472
+ const contexts = yield* ContextService
473
+ const fs = yield* FileSystemService
474
+ const tasks = yield* TaskService
475
+ const phases = yield* PhaseService
476
+ const workbase = yield* WorkbaseService
477
+ const context = yield* contexts.get({
478
+ cwd: startPath,
479
+ target: ".",
480
+ compact: true,
481
+ })
482
+ if (!context.validation.valid) {
483
+ return yield* new PushError({
484
+ message: "Cannot publish from an invalid Agency workbase",
485
+ })
486
+ }
487
+ if (context.target.kind !== "task" && context.target.kind !== "phase") {
488
+ return yield* new PushError({
489
+ message: "agency push must run from an execution task or phase",
490
+ })
491
+ }
492
+ if (
493
+ context.authority.mode !== "execution" ||
494
+ !context.authority.writable
495
+ ) {
496
+ return yield* new PushError({
497
+ message:
498
+ "Current Agency target has no writable execution authority",
499
+ })
500
+ }
501
+ if (
502
+ !context.workspace?.writable?.materialized ||
503
+ !context.workspace.writable.registered
504
+ ) {
505
+ return yield* new PushError({
506
+ message:
507
+ "Current Agency writable checkout is not materialized and registered",
508
+ })
509
+ }
510
+ const blockers = context.graph.readiness.blockers.filter(
511
+ (blocker) =>
512
+ blocker.kind === "dependency" || blocker.kind === "validation",
513
+ )
514
+ if (blockers.length > 0) {
515
+ return yield* new PushError({
516
+ message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
517
+ })
518
+ }
519
+
520
+ const taskId = context.target.taskId
521
+ if (!taskId) {
522
+ return yield* new PushError({
523
+ message: "Current Agency execution target has no task ID",
524
+ })
525
+ }
526
+ const phaseId =
527
+ context.target.kind === "phase" ? context.target.phaseId : undefined
528
+ const task = yield* tasks.show(taskId, context.workbase.root)
529
+ const execution =
530
+ "phases" in task.data
531
+ ? phaseId
532
+ ? (yield* phases.show(taskId, phaseId, context.workbase.root))
533
+ .data
534
+ : null
535
+ : task.data
536
+ if (!execution || "review" in execution) {
537
+ return yield* new PushError({
538
+ message: "Current Agency target is not a delivery execution unit",
539
+ })
540
+ }
541
+ if (execution.status !== "working") {
542
+ return yield* new PushError({
543
+ message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
544
+ })
545
+ }
546
+ const checkout = context.authority.writable.checkoutPath
547
+ const { config } = yield* workbase.loadConfig(context.workbase.root)
548
+ const remote = config.delivery?.remote ?? "origin"
549
+ const published =
550
+ context.workbase.vcs === "jj"
551
+ ? yield* publishJj(
552
+ fs,
553
+ checkout,
554
+ remote,
555
+ execution.branch,
556
+ execution.base,
557
+ )
558
+ : yield* publishGit(
559
+ fs,
560
+ checkout,
561
+ remote,
562
+ execution.branch,
563
+ execution.base,
564
+ )
565
+ return {
566
+ vcs: context.workbase.vcs,
567
+ taskId,
568
+ ...(phaseId ? { phaseId } : {}),
569
+ branch: execution.branch,
570
+ base: execution.base,
571
+ remote,
572
+ ...published,
573
+ } satisfies PushResult
574
+ }),
575
+ }),
576
+ }) {}
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
@@ -76,6 +81,28 @@ task launches remain orchestration-only. External orchestrators instead claim
76
81
  an execution unit, launch and monitor their runner separately, and finish or
77
82
  release the claim with the current document revision.
78
83
 
84
+ An Agency-launched runner receives process-local worker identity through both
85
+ the `AGENCY_SESSION_ID` and `AGENCY_TARGET` environment variables and a generated
86
+ prompt beginning `Agency worker launch target: <target>.` Treat either form as
87
+ launch evidence only after `agency context . --json` confirms the same target,
88
+ document paths, valid context, and expected write authority. Once confirmed,
89
+ perform the assigned work directly and never invoke `agency work` to start the
90
+ same target again.
91
+
92
+ Some runner clients attach to a long-lived process and may not preserve launch
93
+ environment variables. If the variables and prompt marker are absent, fail safe
94
+ when the initial instruction is a generated `Start`, `Continue`, or `Work on`
95
+ prompt whose absolute document paths match the current directory and the active,
96
+ valid `agency context`: treat the process as the current worker and do not
97
+ recursively launch. Herdr state is never part of worker identity. If the prompt
98
+ and context disagree, stop and ask the user rather than launching.
99
+
100
+ For OpenCode, Agency's managed plugin validates the generated marker against
101
+ `agency context`, binds that identity to the OpenCode session, injects an
102
+ active-worker system instruction, and supplies Agency identity to that session's
103
+ shell environment. This avoids relying on the environment of OpenCode's
104
+ long-lived server process.
105
+
79
106
  ## Closeout
80
107
 
81
108
  An execution unit remains `working` after implementation is committed and while