@markjaquith/agency 2.7.3 → 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.
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { WorkbaseService } from "../services/WorkbaseService"
7
+ import { epic } from "./epic"
8
+ import { integration } from "./integration"
9
+ import { phase } from "./phase"
10
+ import { repo } from "./repo"
11
+ import { status } from "./status"
12
+ import { task } from "./task"
13
+ import { validate } from "./validate"
14
+
15
+ const write = async (root: string, path: string, content: string) => {
16
+ const fullPath = join(root, path)
17
+ await mkdir(dirname(fullPath), { recursive: true })
18
+ await Bun.write(fullPath, content)
19
+ }
20
+
21
+ const setReadOnly = async (path: string, readOnly: boolean): Promise<void> => {
22
+ const metadata = await lstat(path)
23
+ if (!metadata.isDirectory()) {
24
+ await chmod(path, readOnly ? 0o444 : 0o644)
25
+ return
26
+ }
27
+
28
+ if (!readOnly) await chmod(path, 0o755)
29
+ for (const entry of await readdir(path)) {
30
+ await setReadOnly(join(path, entry), readOnly)
31
+ }
32
+ if (readOnly) await chmod(path, 0o555)
33
+ }
34
+
35
+ describe("observational commands", () => {
36
+ let root: string
37
+
38
+ beforeEach(async () => {
39
+ root = await createTempDir()
40
+ await write(root, "agency.json", '{"version":2}\n')
41
+ await mkdir(join(root, "repos/agency"), { recursive: true })
42
+ await write(
43
+ root,
44
+ "epics/example/EPIC.md",
45
+ `---
46
+ ticketUrl: https://example.com/epics/example
47
+ repos:
48
+ - repo: agency
49
+ ref: main
50
+ tasks:
51
+ - id: example-task
52
+ ---
53
+
54
+ # Example
55
+ `,
56
+ )
57
+ await write(
58
+ root,
59
+ "tasks/example-task/TASK.md",
60
+ `---
61
+ ticketUrl: null
62
+ epic: example
63
+ phases:
64
+ - id: implementation
65
+ ---
66
+
67
+ # Example task
68
+ `,
69
+ )
70
+ await write(
71
+ root,
72
+ "tasks/example-task/phases/implementation/PHASE.md",
73
+ `---
74
+ repo: agency
75
+ branch: feat/example
76
+ base: main
77
+ pr: null
78
+ status: open
79
+ ---
80
+
81
+ # Implementation
82
+ `,
83
+ )
84
+ })
85
+
86
+ afterEach(async () => {
87
+ await setReadOnly(root, false)
88
+ await cleanupTempDir(root)
89
+ })
90
+
91
+ test("remain usable when the workbase is read-only", async () => {
92
+ await setReadOnly(root, true)
93
+
94
+ await runTestEffect(
95
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
96
+ )
97
+ await runTestEffect(
98
+ integration({ subcommand: "status", cwd: root, silent: true }),
99
+ )
100
+ await runTestEffect(
101
+ epic({ subcommand: "list", args: [], cwd: root, silent: true }),
102
+ )
103
+ await runTestEffect(
104
+ epic({ subcommand: "show", args: ["example"], cwd: root, silent: true }),
105
+ )
106
+ await runTestEffect(
107
+ task({ subcommand: "list", args: [], cwd: root, silent: true }),
108
+ )
109
+ await runTestEffect(
110
+ task({
111
+ subcommand: "show",
112
+ args: ["example-task"],
113
+ cwd: root,
114
+ silent: true,
115
+ }),
116
+ )
117
+ await runTestEffect(
118
+ phase({
119
+ subcommand: "list",
120
+ args: ["example-task"],
121
+ cwd: root,
122
+ silent: true,
123
+ }),
124
+ )
125
+ await runTestEffect(
126
+ phase({
127
+ subcommand: "show",
128
+ args: ["example-task", "implementation"],
129
+ cwd: root,
130
+ silent: true,
131
+ }),
132
+ )
133
+ await runTestEffect(
134
+ repo({ subcommand: "list", args: [], cwd: root, silent: true }),
135
+ )
136
+ await runTestEffect(status({ cwd: root, silent: true }))
137
+ await runTestEffect(validate({ path: root, silent: true }))
138
+
139
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
140
+ expect(
141
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
142
+ ).toBe(false)
143
+ })
144
+ })
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { createHash } from "node:crypto"
4
+ import { mkdir, symlink, unlink } from "node:fs/promises"
5
+ import { dirname, join } from "node:path"
6
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
+ import { managedWorkbaseAgents } from "../workbase/agents-file"
8
+ import { managedWorkbaseOpencode } from "../workbase/opencode-file"
9
+ import { IntegrationService } from "./IntegrationService"
10
+
11
+ const write = async (root: string, path: string, content: string) => {
12
+ const fullPath = join(root, path)
13
+ await mkdir(dirname(fullPath), { recursive: true })
14
+ await Bun.write(fullPath, content)
15
+ }
16
+
17
+ const managed = (prefix: string, body: string, suffix = "") => {
18
+ const checksum = createHash("sha256").update(body).digest("hex")
19
+ return `${prefix}${checksum}${suffix}\n\n${body}`
20
+ }
21
+
22
+ const status = (root: string) =>
23
+ runTestEffect(
24
+ IntegrationService.pipe(Effect.flatMap((service) => service.status(root))),
25
+ )
26
+
27
+ const sync = (root: string) =>
28
+ runTestEffect(
29
+ IntegrationService.pipe(Effect.flatMap((service) => service.sync(root))),
30
+ )
31
+
32
+ describe("IntegrationService", () => {
33
+ let root: string
34
+
35
+ beforeEach(async () => {
36
+ root = await createTempDir()
37
+ await write(root, "agency.json", '{"version":2}\n')
38
+ })
39
+
40
+ afterEach(async () => cleanupTempDir(root))
41
+
42
+ test("reports missing and current managed files without writing", async () => {
43
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
44
+ "missing",
45
+ "missing",
46
+ ])
47
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
48
+
49
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
50
+ await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode(root))
51
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
52
+ "managed",
53
+ "managed",
54
+ ])
55
+ })
56
+
57
+ test("reports customized and checksum-safe drifted files", async () => {
58
+ await write(root, "AGENTS.md", "# Custom instructions\n")
59
+ await write(
60
+ root,
61
+ ".opencode/opencode.jsonc",
62
+ managed("// agency-managed: sha256=", '{"references":{}}\n'),
63
+ )
64
+
65
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
66
+ "customized",
67
+ "drifted",
68
+ ])
69
+ })
70
+
71
+ test("treats an existing JSON OpenCode config as customized", async () => {
72
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
73
+
74
+ const result = await status(root)
75
+ expect(result.files[1]).toEqual({
76
+ name: "opencode",
77
+ path: join(root, ".opencode/opencode.json"),
78
+ state: "customized",
79
+ })
80
+ })
81
+
82
+ test("syncs missing and drifted files while preserving customized files", async () => {
83
+ const customAgents = "# Custom instructions\n"
84
+ await write(root, "AGENTS.md", customAgents)
85
+ await write(
86
+ root,
87
+ ".opencode/opencode.jsonc",
88
+ managed("// agency-managed: sha256=", '{"references":{}}\n'),
89
+ )
90
+
91
+ const first = await sync(root)
92
+ expect(first.files).toMatchObject([
93
+ { name: "agents", state: "customized", changed: false },
94
+ { name: "opencode", state: "managed", changed: true },
95
+ ])
96
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(customAgents)
97
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
98
+ managedWorkbaseOpencode(root),
99
+ )
100
+
101
+ await unlink(join(root, "AGENTS.md"))
102
+ const second = await sync(root)
103
+ expect(second.files[0]).toMatchObject({ state: "managed", changed: true })
104
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
105
+ managedWorkbaseAgents,
106
+ )
107
+ })
108
+
109
+ test("does not overwrite managed files whose checksums no longer match", async () => {
110
+ const tampered = `${managed(
111
+ "<!-- agency-managed: sha256=",
112
+ "# Previous Agency instructions\n",
113
+ " -->",
114
+ )}User edit\n`
115
+ await write(root, "AGENTS.md", tampered)
116
+
117
+ const result = await sync(root)
118
+ expect(result.files[0]).toMatchObject({
119
+ state: "customized",
120
+ changed: false,
121
+ })
122
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(tampered)
123
+ })
124
+
125
+ test("does not follow symlinked integration files", async () => {
126
+ const target = join(root, "custom-agents.md")
127
+ await Bun.write(target, "# External instructions\n")
128
+ await symlink(target, join(root, "AGENTS.md"))
129
+
130
+ const result = await sync(root)
131
+ expect(result.files[0]).toMatchObject({
132
+ state: "customized",
133
+ changed: false,
134
+ })
135
+ expect(await Bun.file(target).text()).toBe("# External instructions\n")
136
+ })
137
+ })