@markjaquith/agency 2.8.0 → 2.9.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 CHANGED
@@ -302,6 +302,7 @@ agency task create <id> --multi-phase
302
302
  ### Noninteractive Use
303
303
 
304
304
  Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
305
+ `--json` also disables prompts and selectors, even when a TTY is available.
305
306
  Commands with explicit inputs continue normally. `task new` fails immediately;
306
307
  `work` requires an explicit directory, task ID, or `--epic` and must run from a
307
308
  workbase; `validate` requires an explicit path or must run from a workbase.
@@ -408,6 +409,66 @@ unknown dependencies, and dependency cycles. YAML duplicate keys, anchors,
408
409
  aliases, and custom tags are rejected. When path is omitted outside a workbase,
409
410
  Agency prompts for a registered workbase.
410
411
 
412
+ ## Machine Protocol
413
+
414
+ `--json` emits exactly one JSON value on stdout for success or failure. It takes
415
+ precedence over `--silent`; progress, warnings, and verbose diagnostics remain on
416
+ stderr. Version 1 success responses have this shape:
417
+
418
+ ```json
419
+ { "version": 1, "ok": true, "result": { "root": "/work/agency" } }
420
+ ```
421
+
422
+ Failures exit nonzero and use the same versioned envelope:
423
+
424
+ ```json
425
+ {
426
+ "version": 1,
427
+ "ok": false,
428
+ "error": {
429
+ "code": "CLI_USAGE",
430
+ "message": "Unknown command 'unknown'.\n\nUsage: agency <command> [options]",
431
+ "fields": {
432
+ "detail": "Unknown command 'unknown'.",
433
+ "usage": "agency <command> [options]"
434
+ },
435
+ "retryable": false,
436
+ "remediation": "Correct the arguments using the usage value in error.fields."
437
+ }
438
+ }
439
+ ```
440
+
441
+ Every error contains a stable `code`, human-readable `message`, structured
442
+ `fields`, and `retryable`. `remediation` is included when Agency knows a specific
443
+ recovery action. Version 1 defines these codes:
444
+
445
+ | Code | Meaning |
446
+ | ------------------------- | -------------------------------------------------------- |
447
+ | `CLI_USAGE` | Invalid command, option, argument, or option combination |
448
+ | `WORKBASE_NOT_FOUND` | No workbase could be resolved |
449
+ | `WORKBASE_CONFIG_INVALID` | Invalid workbase configuration |
450
+ | `WORKBASE_REGISTRY_ERROR` | Invalid or inaccessible workbase registry |
451
+ | `FILE_NOT_FOUND` | A required path does not exist |
452
+ | `FILESYSTEM_ERROR` | A filesystem operation failed |
453
+ | `FRONTMATTER_INVALID` | A durable document has invalid frontmatter |
454
+ | `VALIDATION_FAILED` | Workbase validation reported issues |
455
+ | `REPOSITORY_ERROR` | Repository operation failed |
456
+ | `EPIC_ERROR` | Epic operation failed |
457
+ | `TASK_ERROR` | Task operation failed |
458
+ | `PHASE_ERROR` | Phase operation failed |
459
+ | `ARCHIVE_ERROR` | Archive operation failed |
460
+ | `WORKTREE_ERROR` | Worktree operation failed |
461
+ | `PULL_REQUEST_ERROR` | Pull request operation failed |
462
+ | `PROCESS_ERROR` | A child process failed and may be retried |
463
+ | `PROTOCOL_OUTPUT_ERROR` | A command violated the machine output contract |
464
+ | `COMMAND_FAILED` | An otherwise unclassified command failure |
465
+
466
+ The Effect schemas are exported from `@markjaquith/agency` and
467
+ `@markjaquith/agency/protocol`. The distributable JSON Schema is exported as
468
+ `@markjaquith/agency/schemas/agency-envelope-v1.json`. Representative payloads
469
+ are exported as `@markjaquith/agency/fixtures/protocol/success.json` and
470
+ `@markjaquith/agency/fixtures/protocol/error.json`.
471
+
411
472
  ## Agent Skill
412
473
 
413
474
  `skills/agency/SKILL.md` contains an agent-oriented operating guide for Agency.
package/cli.ts CHANGED
@@ -28,6 +28,12 @@ import { WorktreeService } from "./src/services/WorktreeService"
28
28
  import { PullRequestService } from "./src/services/PullRequestService"
29
29
  import { ArchiveService } from "./src/services/ArchiveService"
30
30
  import { IntegrationService } from "./src/services/IntegrationService"
31
+ import {
32
+ collectCommandResult,
33
+ errorEnvelope,
34
+ successEnvelope,
35
+ writeEnvelope,
36
+ } from "./src/protocol"
31
37
 
32
38
  // Create CLI layer with all services
33
39
  const CliLayer = Layer.mergeAll(
@@ -76,7 +82,7 @@ async function runCommand<E>(
76
82
  Effect.either,
77
83
  ),
78
84
  )
79
- if (Either.isLeft(result)) throw toError(result.left)
85
+ if (Either.isLeft(result)) throw result.left
80
86
  }
81
87
 
82
88
  // Read version from package.json
@@ -346,13 +352,19 @@ For more information about a command, run:
346
352
  `)
347
353
  }
348
354
 
355
+ const machineMode = process.argv.slice(2).includes("--json")
356
+
349
357
  try {
350
358
  const args = process.argv.slice(2)
351
359
  const { commandName, args: commandArgs, values } = parseCli(args)
352
360
 
353
361
  // Handle global flags
354
362
  if (values.version) {
355
- console.log(`v${VERSION}`)
363
+ if (machineMode) {
364
+ writeEnvelope(successEnvelope({ version: VERSION }))
365
+ } else {
366
+ console.log(`v${VERSION}`)
367
+ }
356
368
  process.exit(0)
357
369
  }
358
370
 
@@ -365,9 +377,22 @@ try {
365
377
 
366
378
  const command = commands[commandName]!
367
379
  const inputAllowed =
368
- !values["no-input"] && Boolean(process.stdin.isTTY && process.stderr.isTTY)
369
- await command.run(commandArgs, { ...values, inputAllowed })
380
+ !values.json &&
381
+ !values["no-input"] &&
382
+ Boolean(process.stdin.isTTY && process.stderr.isTTY)
383
+ if (values.json) {
384
+ const result = await collectCommandResult(() =>
385
+ command.run(commandArgs, { ...values, inputAllowed }),
386
+ )
387
+ writeEnvelope(successEnvelope(result))
388
+ } else {
389
+ await command.run(commandArgs, { ...values, inputAllowed })
390
+ }
370
391
  } catch (error) {
392
+ if (machineMode) {
393
+ writeEnvelope(errorEnvelope(error))
394
+ process.exit(1)
395
+ }
371
396
  if (error instanceof Error) {
372
397
  let message = error.message
373
398
 
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "ok": false,
4
+ "error": {
5
+ "code": "CLI_USAGE",
6
+ "message": "Unknown command 'unknown'.\n\nUsage: agency <command> [options]",
7
+ "fields": {
8
+ "detail": "Unknown command 'unknown'.",
9
+ "usage": "agency <command> [options]"
10
+ },
11
+ "retryable": false,
12
+ "remediation": "Correct the arguments using the usage value in error.fields."
13
+ }
14
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 1,
3
+ "ok": true,
4
+ "result": {
5
+ "root": "/work/agency"
6
+ }
7
+ }
package/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./src/workbase/schemas"
2
+ export * from "./src/protocol"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -21,6 +21,8 @@
21
21
  "index.ts",
22
22
  "cli.ts",
23
23
  "src",
24
+ "schemas",
25
+ "fixtures/protocol",
24
26
  "skills",
25
27
  "README.md",
26
28
  "LICENSE"
@@ -31,6 +33,19 @@
31
33
  ".": {
32
34
  "types": "./index.ts",
33
35
  "import": "./index.ts"
36
+ },
37
+ "./protocol": {
38
+ "types": "./src/protocol.ts",
39
+ "import": "./src/protocol.ts"
40
+ },
41
+ "./schemas/agency-envelope-v1.json": {
42
+ "default": "./schemas/agency-envelope-v1.schema.json"
43
+ },
44
+ "./fixtures/protocol/success.json": {
45
+ "default": "./fixtures/protocol/success.json"
46
+ },
47
+ "./fixtures/protocol/error.json": {
48
+ "default": "./fixtures/protocol/error.json"
34
49
  }
35
50
  },
36
51
  "publishConfig": {
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/markjaquith/agency/schemas/agency-envelope-v1.schema.json",
4
+ "title": "Agency machine result envelope v1",
5
+ "oneOf": [
6
+ {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["version", "ok", "result"],
10
+ "properties": {
11
+ "version": { "const": 1 },
12
+ "ok": { "const": true },
13
+ "result": true
14
+ }
15
+ },
16
+ {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["version", "ok", "error"],
20
+ "properties": {
21
+ "version": { "const": 1 },
22
+ "ok": { "const": false },
23
+ "error": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["code", "message", "fields", "retryable"],
27
+ "properties": {
28
+ "code": { "type": "string" },
29
+ "message": { "type": "string" },
30
+ "fields": { "type": "object" },
31
+ "retryable": { "type": "boolean" },
32
+ "remediation": { "type": "string" }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ]
38
+ }
package/src/cli-parser.ts CHANGED
@@ -363,8 +363,20 @@ export interface ParsedCli {
363
363
  >
364
364
  }
365
365
 
366
+ class CliUsageError extends Error {
367
+ readonly _tag = "CliUsageError"
368
+
369
+ constructor(
370
+ readonly detail: string,
371
+ readonly usage: string,
372
+ ) {
373
+ super(`${detail}\n\nUsage: ${usage}`)
374
+ this.name = "CliUsageError"
375
+ }
376
+ }
377
+
366
378
  const usageError = (message: string, usage: string) =>
367
- new Error(`${message}\n\nUsage: ${usage}`)
379
+ new CliUsageError(message, usage)
368
380
 
369
381
  const optionLabel = (name: string) => `--${name}`
370
382
 
package/src/cli.test.ts CHANGED
@@ -34,7 +34,9 @@ async function runCli(
34
34
  function parseJson(result: CliResult) {
35
35
  expect(result.exitCode).toBe(0)
36
36
  expect(result.stderr).toBe("")
37
- return JSON.parse(result.stdout)
37
+ const envelope = JSON.parse(result.stdout)
38
+ expect(envelope).toMatchObject({ version: 1, ok: true })
39
+ return envelope.result
38
40
  }
39
41
 
40
42
  describe("CLI", () => {
@@ -79,6 +81,46 @@ describe("CLI", () => {
79
81
  expect(taggedError.stderr).not.toContain("An error has occurred")
80
82
  })
81
83
 
84
+ test("emits one versioned error envelope for usage and command failures", async () => {
85
+ const usage = await runCli(["unknown", "--json"])
86
+ expect(usage.exitCode).toBe(1)
87
+ expect(usage.stderr).toBe("")
88
+ expect(usage.stdout.trim().split("\n")).toHaveLength(1)
89
+ expect(JSON.parse(usage.stdout)).toEqual({
90
+ version: 1,
91
+ ok: false,
92
+ error: {
93
+ code: "CLI_USAGE",
94
+ message:
95
+ "Unknown command 'unknown'.\n\nUsage: agency <command> [options]",
96
+ fields: {
97
+ detail: "Unknown command 'unknown'.",
98
+ usage: "agency <command> [options]",
99
+ },
100
+ retryable: false,
101
+ remediation:
102
+ "Correct the arguments using the usage value in error.fields.",
103
+ },
104
+ })
105
+
106
+ const cwd = await createTempDir()
107
+ tempDirs.push(cwd)
108
+ const commandFailure = await runCli(
109
+ ["repo", "list", "--json", "--silent"],
110
+ cwd,
111
+ )
112
+ expect(commandFailure.exitCode).toBe(1)
113
+ expect(commandFailure.stderr).toBe("")
114
+ expect(JSON.parse(commandFailure.stdout)).toMatchObject({
115
+ version: 1,
116
+ ok: false,
117
+ error: {
118
+ code: "WORKBASE_NOT_FOUND",
119
+ retryable: false,
120
+ },
121
+ })
122
+ })
123
+
82
124
  test("rejects malformed input before running a command", async () => {
83
125
  const parent = await createTempDir()
84
126
  tempDirs.push(parent)
@@ -178,6 +220,30 @@ describe("CLI", () => {
178
220
  ).toEqual([await realpath(root)])
179
221
  })
180
222
 
223
+ test("lets JSON override silent and disables interactive task input", async () => {
224
+ const root = await createTempDir()
225
+ tempDirs.push(root)
226
+ const result = await runCli(["init", root, "--json", "--silent"])
227
+ expect(parseJson(result)).toEqual({ root })
228
+
229
+ const interactive = await runCli(["task", "new", "--json"])
230
+ expect(interactive.exitCode).toBe(1)
231
+ expect(interactive.stderr).toBe("")
232
+ expect(JSON.parse(interactive.stdout)).toMatchObject({
233
+ version: 1,
234
+ ok: false,
235
+ error: { code: "COMMAND_FAILED", retryable: false },
236
+ })
237
+ })
238
+
239
+ test("envelopes help and version output in machine mode", async () => {
240
+ const help = await runCli(["status", "--help", "--json"])
241
+ expect(parseJson(help)).toContain("Usage: agency status")
242
+
243
+ const version = await runCli(["status", "--version", "--json"])
244
+ expect(parseJson(version)).toEqual({ version: "0.0.0-development" })
245
+ })
246
+
181
247
  test("runs a multi-phase domain workflow through subprocesses", async () => {
182
248
  const parent = await createTempDir()
183
249
  tempDirs.push(parent)
@@ -15,6 +15,11 @@ interface ValidateOptions extends BaseCommandOptions {
15
15
 
16
16
  class ValidationFailedError extends Data.TaggedError("ValidationFailedError")<{
17
17
  readonly message: string
18
+ readonly root: string
19
+ readonly issues: readonly {
20
+ readonly path: string
21
+ readonly message: string
22
+ }[]
18
23
  }> {}
19
24
 
20
25
  export const validate = (
@@ -46,6 +51,8 @@ export const validate = (
46
51
  .join("\n")
47
52
  return yield* new ValidationFailedError({
48
53
  message: `Workbase validation failed with ${report.issues.length} issue${report.issues.length === 1 ? "" : "s"}:\n${details}`,
54
+ root: report.root,
55
+ issues: report.issues,
49
56
  })
50
57
  }
51
58
 
@@ -6,7 +6,7 @@ import { EpicService } from "../services/EpicService"
6
6
  import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
- import { captureLogs } from "../test-utils"
9
+ import { captureErrors, captureLogs } from "../test-utils"
10
10
  import { work } from "./work"
11
11
  import type { PickWorkTarget } from "../workbase/work-target"
12
12
  import type { PickWorkbase } from "../workbase/workbase-choice"
@@ -568,7 +568,7 @@ describe("work command", () => {
568
568
 
569
569
  test("respects silent and verbose logging options", async () => {
570
570
  const verboseHarness = createHarness()
571
- const verboseLogs = await captureLogs(() =>
571
+ const verboseLogs = await captureErrors(() =>
572
572
  verboseHarness.run({ taskId: "example", verbose: true }),
573
573
  )
574
574
  expect(verboseLogs).toEqual([
@@ -577,7 +577,7 @@ describe("work command", () => {
577
577
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
578
578
 
579
579
  const silentHarness = createHarness()
580
- const silentLogs = await captureLogs(() =>
580
+ const silentLogs = await captureErrors(() =>
581
581
  silentHarness.run({
582
582
  taskId: "example",
583
583
  verbose: true,
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Schema } from "@effect/schema"
3
+ import errorFixture from "../fixtures/protocol/error.json"
4
+ import successFixture from "../fixtures/protocol/success.json"
5
+ import jsonSchema from "../schemas/agency-envelope-v1.schema.json"
6
+ import {
7
+ AgencyEnvelope,
8
+ collectCommandResult,
9
+ emitCommandResult,
10
+ errorEnvelope,
11
+ successEnvelope,
12
+ } from "./protocol"
13
+
14
+ describe("machine protocol", () => {
15
+ test("accepts the representative success and error fixtures", () => {
16
+ for (const fixture of [successFixture, errorFixture]) {
17
+ const decoded = Schema.decodeUnknownSync(AgencyEnvelope, {
18
+ onExcessProperty: "error",
19
+ })(fixture)
20
+ expect(JSON.stringify(decoded)).toBe(JSON.stringify(fixture))
21
+ }
22
+ })
23
+
24
+ test("publishes the matching v1 JSON Schema", () => {
25
+ expect(jsonSchema).toMatchObject({
26
+ $schema: "https://json-schema.org/draft/2020-12/schema",
27
+ title: "Agency machine result envelope v1",
28
+ oneOf: [
29
+ { properties: { version: { const: 1 }, ok: { const: true } } },
30
+ { properties: { version: { const: 1 }, ok: { const: false } } },
31
+ ],
32
+ })
33
+ })
34
+
35
+ test("collects one command result without writing it", async () => {
36
+ const result = await collectCommandResult(async () => {
37
+ emitCommandResult('{"value":42}')
38
+ })
39
+ expect(successEnvelope(result)).toEqual({
40
+ version: 1,
41
+ ok: true,
42
+ result: { value: 42 },
43
+ })
44
+ expect(successEnvelope(undefined).result).toBeNull()
45
+ })
46
+
47
+ test("rejects commands that emit multiple machine results", async () => {
48
+ await expect(
49
+ collectCommandResult(async () => {
50
+ emitCommandResult("first")
51
+ emitCommandResult("second")
52
+ }),
53
+ ).rejects.toThrow("more than one result")
54
+ })
55
+
56
+ test("normalizes unknown failures into stable error details", () => {
57
+ expect(errorEnvelope(new Error("boom"))).toEqual({
58
+ version: 1,
59
+ ok: false,
60
+ error: {
61
+ code: "COMMAND_FAILED",
62
+ message: "boom",
63
+ fields: {},
64
+ retryable: false,
65
+ },
66
+ })
67
+ })
68
+
69
+ test("preserves relevant fields from classified errors", () => {
70
+ expect(
71
+ errorEnvelope({
72
+ _tag: "ValidationFailedError",
73
+ message: "invalid workbase",
74
+ root: "/work/agency",
75
+ issues: [{ path: "TASK.md", message: "invalid status" }],
76
+ }),
77
+ ).toMatchObject({
78
+ error: {
79
+ code: "VALIDATION_FAILED",
80
+ fields: {
81
+ root: "/work/agency",
82
+ issues: [{ path: "TASK.md", message: "invalid status" }],
83
+ },
84
+ },
85
+ })
86
+ })
87
+ })
@@ -0,0 +1,211 @@
1
+ import { Schema } from "@effect/schema"
2
+
3
+ export const PROTOCOL_VERSION = 1 as const
4
+
5
+ const ErrorFields = Schema.Record({
6
+ key: Schema.String,
7
+ value: Schema.Unknown,
8
+ })
9
+
10
+ export const SuccessEnvelope = Schema.Struct({
11
+ version: Schema.Literal(PROTOCOL_VERSION),
12
+ ok: Schema.Literal(true),
13
+ result: Schema.Unknown,
14
+ })
15
+
16
+ export const ErrorDetail = Schema.Struct({
17
+ code: Schema.String,
18
+ message: Schema.String,
19
+ fields: ErrorFields,
20
+ retryable: Schema.Boolean,
21
+ remediation: Schema.optional(Schema.String),
22
+ })
23
+
24
+ export const ErrorEnvelope = Schema.Struct({
25
+ version: Schema.Literal(PROTOCOL_VERSION),
26
+ ok: Schema.Literal(false),
27
+ error: ErrorDetail,
28
+ })
29
+
30
+ export const AgencyEnvelope = Schema.Union(SuccessEnvelope, ErrorEnvelope)
31
+
32
+ export type SuccessEnvelope = Schema.Schema.Type<typeof SuccessEnvelope>
33
+ export type ErrorEnvelope = Schema.Schema.Type<typeof ErrorEnvelope>
34
+ export type AgencyEnvelope = Schema.Schema.Type<typeof AgencyEnvelope>
35
+
36
+ interface ErrorMetadata {
37
+ readonly code: string
38
+ readonly retryable: boolean
39
+ readonly remediation?: string
40
+ }
41
+
42
+ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
43
+ CliUsageError: {
44
+ code: "CLI_USAGE",
45
+ retryable: false,
46
+ remediation: "Correct the arguments using the usage value in error.fields.",
47
+ },
48
+ WorkbaseNotFoundError: {
49
+ code: "WORKBASE_NOT_FOUND",
50
+ retryable: false,
51
+ remediation:
52
+ "Run the command from an Agency workbase or provide an explicit workbase path.",
53
+ },
54
+ WorkbaseConfigError: {
55
+ code: "WORKBASE_CONFIG_INVALID",
56
+ retryable: false,
57
+ remediation: "Correct the workbase configuration and retry the command.",
58
+ },
59
+ WorkbaseRegistryError: {
60
+ code: "WORKBASE_REGISTRY_ERROR",
61
+ retryable: false,
62
+ remediation: "Correct the registered workbase entry and retry the command.",
63
+ },
64
+ FileNotFoundError: {
65
+ code: "FILE_NOT_FOUND",
66
+ retryable: false,
67
+ remediation: "Restore the required file or correct the supplied path.",
68
+ },
69
+ FileSystemError: { code: "FILESYSTEM_ERROR", retryable: false },
70
+ FrontmatterParseError: {
71
+ code: "FRONTMATTER_INVALID",
72
+ retryable: false,
73
+ remediation: "Correct the document frontmatter and retry the command.",
74
+ },
75
+ ValidationFailedError: {
76
+ code: "VALIDATION_FAILED",
77
+ retryable: false,
78
+ remediation: "Resolve the validation issues in error.fields and retry.",
79
+ },
80
+ RepositoryError: { code: "REPOSITORY_ERROR", retryable: false },
81
+ EpicError: { code: "EPIC_ERROR", retryable: false },
82
+ TaskError: { code: "TASK_ERROR", retryable: false },
83
+ PhaseError: { code: "PHASE_ERROR", retryable: false },
84
+ ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
85
+ WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
86
+ PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
87
+ ProcessError: { code: "PROCESS_ERROR", retryable: true },
88
+ ProtocolOutputError: {
89
+ code: "PROTOCOL_OUTPUT_ERROR",
90
+ retryable: false,
91
+ remediation: "Report this Agency protocol violation.",
92
+ },
93
+ }
94
+
95
+ class ProtocolOutputError extends Error {
96
+ readonly _tag = "ProtocolOutputError"
97
+ }
98
+
99
+ let resultCollector: ((value: unknown) => void) | undefined
100
+
101
+ const parseCommandResult = (value: unknown): unknown => {
102
+ if (typeof value !== "string") return value
103
+ try {
104
+ return JSON.parse(value)
105
+ } catch {
106
+ return value
107
+ }
108
+ }
109
+
110
+ export const emitCommandResult = (value: unknown): void => {
111
+ if (resultCollector) {
112
+ resultCollector(parseCommandResult(value))
113
+ return
114
+ }
115
+ console.log(value)
116
+ }
117
+
118
+ export const collectCommandResult = async (
119
+ run: () => Promise<void>,
120
+ ): Promise<unknown> => {
121
+ if (resultCollector) {
122
+ throw new ProtocolOutputError(
123
+ "A machine result collector is already active.",
124
+ )
125
+ }
126
+
127
+ let emitted = false
128
+ let result: unknown = null
129
+ const originalLog = console.log
130
+ resultCollector = (value) => {
131
+ if (emitted) {
132
+ throw new ProtocolOutputError(
133
+ "A machine command emitted more than one result.",
134
+ )
135
+ }
136
+ emitted = true
137
+ result = value
138
+ }
139
+ console.log = (...values) => {
140
+ resultCollector?.(
141
+ parseCommandResult(values.length === 1 ? values[0] : values.join(" ")),
142
+ )
143
+ }
144
+
145
+ try {
146
+ await run()
147
+ return result
148
+ } finally {
149
+ console.log = originalLog
150
+ resultCollector = undefined
151
+ }
152
+ }
153
+
154
+ const errorTag = (error: unknown): string | undefined => {
155
+ if (typeof error !== "object" || error === null) return undefined
156
+ if ("_tag" in error && typeof error._tag === "string") return error._tag
157
+ if (error instanceof Error && error.name !== "Error") return error.name
158
+ return undefined
159
+ }
160
+
161
+ const errorMessage = (error: unknown): string => {
162
+ if (
163
+ typeof error === "object" &&
164
+ error !== null &&
165
+ "message" in error &&
166
+ typeof error.message === "string"
167
+ ) {
168
+ return error.message
169
+ }
170
+ return String(error)
171
+ }
172
+
173
+ const errorFields = (error: unknown): Record<string, unknown> => {
174
+ if (typeof error !== "object" || error === null) return {}
175
+ return Object.fromEntries(
176
+ Object.entries(error).filter(
177
+ ([key, value]) =>
178
+ !key.startsWith("_") &&
179
+ key !== "name" &&
180
+ key !== "message" &&
181
+ key !== "cause" &&
182
+ value !== undefined,
183
+ ),
184
+ )
185
+ }
186
+
187
+ export const successEnvelope = (result: unknown): SuccessEnvelope => ({
188
+ version: PROTOCOL_VERSION,
189
+ ok: true,
190
+ result: result === undefined ? null : result,
191
+ })
192
+
193
+ export const errorEnvelope = (error: unknown): ErrorEnvelope => {
194
+ const metadata = errorMetadata[errorTag(error) ?? ""] ?? {
195
+ code: "COMMAND_FAILED",
196
+ retryable: false,
197
+ }
198
+ return {
199
+ version: PROTOCOL_VERSION,
200
+ ok: false,
201
+ error: {
202
+ ...metadata,
203
+ message: errorMessage(error),
204
+ fields: errorFields(error),
205
+ },
206
+ }
207
+ }
208
+
209
+ export const writeEnvelope = (envelope: AgencyEnvelope): void => {
210
+ process.stdout.write(`${JSON.stringify(envelope)}\n`)
211
+ }
@@ -3,7 +3,7 @@ import { Effect } from "effect"
3
3
  import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
- captureLogs,
6
+ captureErrors,
7
7
  cleanupTempDir,
8
8
  createTempDir,
9
9
  runTestEffect,
@@ -287,7 +287,7 @@ describe("WorktreeService", () => {
287
287
  ),
288
288
  )
289
289
 
290
- const logs = await captureLogs(() =>
290
+ const logs = await captureErrors(() =>
291
291
  runTestEffect(
292
292
  WorktreeService.pipe(
293
293
  Effect.flatMap((service) =>
package/src/test-utils.ts CHANGED
@@ -45,17 +45,24 @@ export async function runTestEffect<A, E>(
45
45
  return Effect.runPromise(program)
46
46
  }
47
47
 
48
- export async function captureLogs(
48
+ async function captureConsole(
49
+ method: "log" | "error",
49
50
  run: () => Promise<unknown>,
50
51
  ): Promise<string[]> {
51
52
  const logs: string[] = []
52
- const log = spyOn(console, "log").mockImplementation((...args) => {
53
+ const output = spyOn(console, method).mockImplementation((...args) => {
53
54
  logs.push(args.join(" "))
54
55
  })
55
56
  try {
56
57
  await run()
57
58
  return logs
58
59
  } finally {
59
- log.mockRestore()
60
+ output.mockRestore()
60
61
  }
61
62
  }
63
+
64
+ export const captureLogs = (run: () => Promise<unknown>) =>
65
+ captureConsole("log", run)
66
+
67
+ export const captureErrors = (run: () => Promise<unknown>) =>
68
+ captureConsole("error", run)
@@ -1,24 +1,30 @@
1
- import { describe, expect, test } from "bun:test"
1
+ import { describe, expect, spyOn, test } from "bun:test"
2
2
  import { captureLogs } from "../test-utils"
3
3
  import { createLoggers } from "./effect"
4
4
 
5
5
  describe("createLoggers", () => {
6
6
  test("keeps JSON output machine-readable when verbose is enabled", async () => {
7
+ const errors: string[] = []
8
+ const error = spyOn(console, "error").mockImplementation((...args) => {
9
+ errors.push(args.join(" "))
10
+ })
7
11
  const logs = await captureLogs(async () => {
8
12
  const { log, verboseLog } = createLoggers({ json: true, verbose: true })
9
13
  verboseLog("debug")
10
14
  log('{"ok":true}')
11
15
  })
16
+ error.mockRestore()
12
17
 
13
18
  expect(logs).toEqual(['{"ok":true}'])
19
+ expect(errors).toEqual(["debug"])
14
20
  })
15
21
 
16
- test("lets silent suppress JSON output", async () => {
22
+ test("lets JSON output override silent", async () => {
17
23
  const logs = await captureLogs(async () => {
18
24
  const { log } = createLoggers({ json: true, silent: true })
19
25
  log('{"ok":true}')
20
26
  })
21
27
 
22
- expect(logs).toEqual([])
28
+ expect(logs).toEqual(['{"ok":true}'])
23
29
  })
24
30
  })
@@ -1,3 +1,5 @@
1
+ import { emitCommandResult } from "../protocol"
2
+
1
3
  export function createLoggers(options: {
2
4
  readonly silent?: boolean
3
5
  readonly verbose?: boolean
@@ -5,7 +7,7 @@ export function createLoggers(options: {
5
7
  }) {
6
8
  const { silent = false, verbose = false, json = false } = options
7
9
  return {
8
- log: silent ? () => {} : console.log,
9
- verboseLog: verbose && !silent && !json ? console.log : () => {},
10
+ log: json ? emitCommandResult : silent ? () => {} : console.log,
11
+ verboseLog: verbose && !silent ? console.error : () => {},
10
12
  }
11
13
  }