@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 +61 -0
- package/cli.ts +29 -4
- package/fixtures/protocol/error.json +14 -0
- package/fixtures/protocol/success.json +7 -0
- package/index.ts +1 -0
- package/package.json +16 -1
- package/schemas/agency-envelope-v1.schema.json +38 -0
- package/src/cli-parser.ts +13 -1
- package/src/cli.test.ts +67 -1
- package/src/commands/validate.ts +7 -0
- package/src/commands/work.test.ts +3 -3
- package/src/protocol.test.ts +87 -0
- package/src/protocol.ts +211 -0
- package/src/services/WorktreeService.test.ts +2 -2
- package/src/test-utils.ts +10 -3
- package/src/utils/effect.test.ts +9 -3
- package/src/utils/effect.ts +4 -2
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
|
|
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
|
-
|
|
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
|
|
369
|
-
|
|
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
|
+
}
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/commands/validate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
})
|
package/src/protocol.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
+
async function captureConsole(
|
|
49
|
+
method: "log" | "error",
|
|
49
50
|
run: () => Promise<unknown>,
|
|
50
51
|
): Promise<string[]> {
|
|
51
52
|
const logs: string[] = []
|
|
52
|
-
const
|
|
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
|
-
|
|
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)
|
package/src/utils/effect.test.ts
CHANGED
|
@@ -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
|
|
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
|
})
|
package/src/utils/effect.ts
CHANGED
|
@@ -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
|
|
10
|
+
log: json ? emitCommandResult : silent ? () => {} : console.log,
|
|
11
|
+
verboseLog: verbose && !silent ? console.error : () => {},
|
|
10
12
|
}
|
|
11
13
|
}
|