@markjaquith/agency 2.8.0 → 2.10.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
@@ -231,12 +231,30 @@ agency repo link backend ~/Dev/backend
231
231
  agency task new
232
232
 
233
233
  agency validate
234
+ agency context tasks/refresh-copy --json
234
235
  agency work tasks/refresh-copy
235
236
  agency pr create refresh-copy
236
237
  ```
237
238
 
238
239
  ## Commands
239
240
 
241
+ ### Target Context
242
+
243
+ `agency context [target] --json` returns the complete bootstrap context for an
244
+ epic, task, or phase without modifying the workbase or fetching repositories.
245
+ The target defaults to the current directory; entity directories, document
246
+ paths, checkout descendants, and bare task IDs are accepted.
247
+
248
+ The result includes workbase and target identity, ancestor frontmatter and prose
249
+ with SHA-256 hashes, dependency and readiness state, aggregate status, writable
250
+ and reference authority, local checkout and resolved-commit state, recorded PR
251
+ state, and validation warnings. Only `done` satisfies a dependency; `dropped` is
252
+ terminal but remains a blocker.
253
+
254
+ Complete output is the default. Pass `--compact` explicitly to omit document
255
+ prose and low-level Git details while retaining identity, hashes, authority,
256
+ paths, graph state, materialization state, and validation warnings.
257
+
240
258
  ### Workbase and Repositories
241
259
 
242
260
  ```text
@@ -302,6 +320,7 @@ agency task create <id> --multi-phase
302
320
  ### Noninteractive Use
303
321
 
304
322
  Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
323
+ `--json` also disables prompts and selectors, even when a TTY is available.
305
324
  Commands with explicit inputs continue normally. `task new` fails immediately;
306
325
  `work` requires an explicit directory, task ID, or `--epic` and must run from a
307
326
  workbase; `validate` requires an explicit path or must run from a workbase.
@@ -408,6 +427,66 @@ unknown dependencies, and dependency cycles. YAML duplicate keys, anchors,
408
427
  aliases, and custom tags are rejected. When path is omitted outside a workbase,
409
428
  Agency prompts for a registered workbase.
410
429
 
430
+ ## Machine Protocol
431
+
432
+ `--json` emits exactly one JSON value on stdout for success or failure. It takes
433
+ precedence over `--silent`; progress, warnings, and verbose diagnostics remain on
434
+ stderr. Version 1 success responses have this shape:
435
+
436
+ ```json
437
+ { "version": 1, "ok": true, "result": { "root": "/work/agency" } }
438
+ ```
439
+
440
+ Failures exit nonzero and use the same versioned envelope:
441
+
442
+ ```json
443
+ {
444
+ "version": 1,
445
+ "ok": false,
446
+ "error": {
447
+ "code": "CLI_USAGE",
448
+ "message": "Unknown command 'unknown'.\n\nUsage: agency <command> [options]",
449
+ "fields": {
450
+ "detail": "Unknown command 'unknown'.",
451
+ "usage": "agency <command> [options]"
452
+ },
453
+ "retryable": false,
454
+ "remediation": "Correct the arguments using the usage value in error.fields."
455
+ }
456
+ }
457
+ ```
458
+
459
+ Every error contains a stable `code`, human-readable `message`, structured
460
+ `fields`, and `retryable`. `remediation` is included when Agency knows a specific
461
+ recovery action. Version 1 defines these codes:
462
+
463
+ | Code | Meaning |
464
+ | ------------------------- | -------------------------------------------------------- |
465
+ | `CLI_USAGE` | Invalid command, option, argument, or option combination |
466
+ | `WORKBASE_NOT_FOUND` | No workbase could be resolved |
467
+ | `WORKBASE_CONFIG_INVALID` | Invalid workbase configuration |
468
+ | `WORKBASE_REGISTRY_ERROR` | Invalid or inaccessible workbase registry |
469
+ | `FILE_NOT_FOUND` | A required path does not exist |
470
+ | `FILESYSTEM_ERROR` | A filesystem operation failed |
471
+ | `FRONTMATTER_INVALID` | A durable document has invalid frontmatter |
472
+ | `VALIDATION_FAILED` | Workbase validation reported issues |
473
+ | `REPOSITORY_ERROR` | Repository operation failed |
474
+ | `EPIC_ERROR` | Epic operation failed |
475
+ | `TASK_ERROR` | Task operation failed |
476
+ | `PHASE_ERROR` | Phase operation failed |
477
+ | `ARCHIVE_ERROR` | Archive operation failed |
478
+ | `WORKTREE_ERROR` | Worktree operation failed |
479
+ | `PULL_REQUEST_ERROR` | Pull request operation failed |
480
+ | `PROCESS_ERROR` | A child process failed and may be retried |
481
+ | `PROTOCOL_OUTPUT_ERROR` | A command violated the machine output contract |
482
+ | `COMMAND_FAILED` | An otherwise unclassified command failure |
483
+
484
+ The Effect schemas are exported from `@markjaquith/agency` and
485
+ `@markjaquith/agency/protocol`. The distributable JSON Schema is exported as
486
+ `@markjaquith/agency/schemas/agency-envelope-v1.json`. Representative payloads
487
+ are exported as `@markjaquith/agency/fixtures/protocol/success.json` and
488
+ `@markjaquith/agency/fixtures/protocol/error.json`.
489
+
411
490
  ## Agent Skill
412
491
 
413
492
  `skills/agency/SKILL.md` contains an agent-oriented operating guide for Agency.
package/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { pr, help as prHelp } from "./src/commands/pr"
8
8
  import { work, help as workHelp } from "./src/commands/work"
9
9
  import { status, help as statusHelp } from "./src/commands/status"
10
10
  import { validate, help as validateHelp } from "./src/commands/validate"
11
+ import { context, help as contextHelp } from "./src/commands/context"
11
12
  import { repo, help as repoHelp } from "./src/commands/repo"
12
13
  import { epic, help as epicHelp } from "./src/commands/epic"
13
14
  import { phase, help as phaseHelp } from "./src/commands/phase"
@@ -28,6 +29,13 @@ import { WorktreeService } from "./src/services/WorktreeService"
28
29
  import { PullRequestService } from "./src/services/PullRequestService"
29
30
  import { ArchiveService } from "./src/services/ArchiveService"
30
31
  import { IntegrationService } from "./src/services/IntegrationService"
32
+ import { ContextService } from "./src/services/ContextService"
33
+ import {
34
+ collectCommandResult,
35
+ errorEnvelope,
36
+ successEnvelope,
37
+ writeEnvelope,
38
+ } from "./src/protocol"
31
39
 
32
40
  // Create CLI layer with all services
33
41
  const CliLayer = Layer.mergeAll(
@@ -41,6 +49,7 @@ const CliLayer = Layer.mergeAll(
41
49
  PullRequestService.Default,
42
50
  ArchiveService.Default,
43
51
  IntegrationService.Default,
52
+ ContextService.Default,
44
53
  )
45
54
 
46
55
  /**
@@ -76,7 +85,7 @@ async function runCommand<E>(
76
85
  Effect.either,
77
86
  ),
78
87
  )
79
- if (Either.isLeft(result)) throw toError(result.left)
88
+ if (Either.isLeft(result)) throw result.left
80
89
  }
81
90
 
82
91
  // Read version from package.json
@@ -307,6 +316,23 @@ const commands: Record<string, Command> = {
307
316
  )
308
317
  },
309
318
  },
319
+ context: {
320
+ run: async (args: string[], options: Record<string, any>) => {
321
+ if (options.help) {
322
+ console.log(contextHelp)
323
+ return
324
+ }
325
+ await runCommand(
326
+ context({
327
+ target: args[0],
328
+ compact: options.compact,
329
+ json: options.json,
330
+ silent: options.silent,
331
+ verbose: options.verbose,
332
+ }),
333
+ )
334
+ },
335
+ },
310
336
  }
311
337
 
312
338
  function showMainHelp() {
@@ -328,6 +354,7 @@ Commands:
328
354
  repo <subcommand> Manage workbase repositories
329
355
  status Show status for the current workbase
330
356
  validate [path] Validate a workbase
357
+ context [target] Return complete target context
331
358
 
332
359
  Global Options:
333
360
  -h, --help Show help for a command
@@ -346,13 +373,19 @@ For more information about a command, run:
346
373
  `)
347
374
  }
348
375
 
376
+ const machineMode = process.argv.slice(2).includes("--json")
377
+
349
378
  try {
350
379
  const args = process.argv.slice(2)
351
380
  const { commandName, args: commandArgs, values } = parseCli(args)
352
381
 
353
382
  // Handle global flags
354
383
  if (values.version) {
355
- console.log(`v${VERSION}`)
384
+ if (machineMode) {
385
+ writeEnvelope(successEnvelope({ version: VERSION }))
386
+ } else {
387
+ console.log(`v${VERSION}`)
388
+ }
356
389
  process.exit(0)
357
390
  }
358
391
 
@@ -365,9 +398,22 @@ try {
365
398
 
366
399
  const command = commands[commandName]!
367
400
  const inputAllowed =
368
- !values["no-input"] && Boolean(process.stdin.isTTY && process.stderr.isTTY)
369
- await command.run(commandArgs, { ...values, inputAllowed })
401
+ !values.json &&
402
+ !values["no-input"] &&
403
+ Boolean(process.stdin.isTTY && process.stderr.isTTY)
404
+ if (values.json) {
405
+ const result = await collectCommandResult(() =>
406
+ command.run(commandArgs, { ...values, inputAllowed }),
407
+ )
408
+ writeEnvelope(successEnvelope(result))
409
+ } else {
410
+ await command.run(commandArgs, { ...values, inputAllowed })
411
+ }
370
412
  } catch (error) {
413
+ if (machineMode) {
414
+ writeEnvelope(errorEnvelope(error))
415
+ process.exit(1)
416
+ }
371
417
  if (error instanceof Error) {
372
418
  let message = error.message
373
419
 
@@ -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.10.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
+ }
@@ -139,11 +139,21 @@ describe("strict CLI parsing", () => {
139
139
  [["pr", "create", "one", "two", "three"], "agency pr create"],
140
140
  [["status", "extra"], "agency status"],
141
141
  [["validate", "one", "two"], "agency validate"],
142
+ [["context", "one", "two"], "agency context"],
142
143
  ] as const) {
143
144
  expectUsageError([...args], usage)
144
145
  }
145
146
  })
146
147
 
148
+ test("accepts context projections and keeps compact command-local", () => {
149
+ expect(parseCli(["context", ".", "--json", "--compact"])).toMatchObject({
150
+ commandName: "context",
151
+ args: ["."],
152
+ values: { json: true, compact: true },
153
+ })
154
+ expectUsageError(["status", "--compact"], "agency status")
155
+ })
156
+
147
157
  test("reports unknown subcommands with parent usage", () => {
148
158
  expect(() => parseCli(["task", "crate"])).toThrow(
149
159
  "Unknown subcommand 'crate'",
package/src/cli-parser.ts CHANGED
@@ -338,6 +338,19 @@ const commands = {
338
338
  options: ["json"],
339
339
  },
340
340
  },
341
+ context: {
342
+ usage: "agency context [target] [--json] [--compact]",
343
+ options: {
344
+ ...outputOptions,
345
+ compact: { type: "boolean" },
346
+ },
347
+ command: {
348
+ usage: "agency context [target] [--json] [--compact]",
349
+ minArgs: 0,
350
+ maxArgs: 1,
351
+ options: ["json", "compact"],
352
+ },
353
+ },
341
354
  } satisfies Readonly<Record<string, CommandDefinition>>
342
355
 
343
356
  const rootOptions = commonOptions
@@ -363,8 +376,20 @@ export interface ParsedCli {
363
376
  >
364
377
  }
365
378
 
379
+ class CliUsageError extends Error {
380
+ readonly _tag = "CliUsageError"
381
+
382
+ constructor(
383
+ readonly detail: string,
384
+ readonly usage: string,
385
+ ) {
386
+ super(`${detail}\n\nUsage: ${usage}`)
387
+ this.name = "CliUsageError"
388
+ }
389
+ }
390
+
366
391
  const usageError = (message: string, usage: string) =>
367
- new Error(`${message}\n\nUsage: ${usage}`)
392
+ new CliUsageError(message, usage)
368
393
 
369
394
  const optionLabel = (name: string) => `--${name}`
370
395
 
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)
@@ -116,6 +158,7 @@ describe("CLI", () => {
116
158
  ["pr", "Usage: agency pr"],
117
159
  ["status", "Usage: agency status"],
118
160
  ["validate", "Usage: agency validate"],
161
+ ["context", "Usage: agency context"],
119
162
  ] as const) {
120
163
  const result = await runCli([command, "--help"])
121
164
  expect(result.exitCode).toBe(0)
@@ -178,6 +221,30 @@ describe("CLI", () => {
178
221
  ).toEqual([await realpath(root)])
179
222
  })
180
223
 
224
+ test("lets JSON override silent and disables interactive task input", async () => {
225
+ const root = await createTempDir()
226
+ tempDirs.push(root)
227
+ const result = await runCli(["init", root, "--json", "--silent"])
228
+ expect(parseJson(result)).toEqual({ root })
229
+
230
+ const interactive = await runCli(["task", "new", "--json"])
231
+ expect(interactive.exitCode).toBe(1)
232
+ expect(interactive.stderr).toBe("")
233
+ expect(JSON.parse(interactive.stdout)).toMatchObject({
234
+ version: 1,
235
+ ok: false,
236
+ error: { code: "COMMAND_FAILED", retryable: false },
237
+ })
238
+ })
239
+
240
+ test("envelopes help and version output in machine mode", async () => {
241
+ const help = await runCli(["status", "--help", "--json"])
242
+ expect(parseJson(help)).toContain("Usage: agency status")
243
+
244
+ const version = await runCli(["status", "--version", "--json"])
245
+ expect(parseJson(version)).toEqual({ version: "0.0.0-development" })
246
+ })
247
+
181
248
  test("runs a multi-phase domain workflow through subprocesses", async () => {
182
249
  const parent = await createTempDir()
183
250
  tempDirs.push(parent)