@markjaquith/agency 2.35.0 → 2.37.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
@@ -47,9 +47,11 @@ and customized documents remain valid.
47
47
 
48
48
  ```text
49
49
  workbase/
50
- AGENTS.md # managed workbase instructions
50
+ AGENTS.md # optional user-owned workbase instructions
51
+ .agency/
52
+ AGENTS.md # managed Agency instructions
51
53
  .opencode/
52
- opencode.jsonc # managed whole-workbase reference
54
+ opencode.jsonc # managed instructions and whole-workbase reference
53
55
  agency.json # tracked config and portable repository declarations
54
56
  repos/ # ignored local materializations
55
57
  frontend/ # bare Git repository or symlink
@@ -77,18 +79,26 @@ workbase/
77
79
  ```
78
80
 
79
81
  Agency keeps discovery and other observational commands read-only. Run
80
- `agency integration status` to inspect `AGENTS.md` and
82
+ `agency integration status` to inspect `.agency/AGENTS.md` and
81
83
  `.opencode/opencode.jsonc`, then `agency integration sync` to create missing
82
84
  files or refresh checksum-safe managed files. Customized files are reported but
83
- never overwritten.
84
-
85
- The OpenCode config advertises the complete workbase as one portable relative
86
- reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and injects
87
- runtime-only absolute rules that allow external access across the workbase while
88
- denying edits outside the execution unit's writable checkout. This makes the
89
- config effective from nested task and phase Git checkouts without persisting a
90
- machine-specific path or granting access outside the workbase. Bash and Agency
91
- operations must still follow the write authority reported by `agency context`.
85
+ never overwritten. The root `AGENTS.md` is user-owned and is not inspected or
86
+ modified by Agency.
87
+
88
+ When upgrading an existing workbase, synchronization moves a checksum-valid
89
+ Agency-managed root `AGENTS.md` to `.agency/AGENTS.md` once the OpenCode config
90
+ can load the hidden file. A customized root file, including a symlink, is
91
+ preserved as user-owned content.
92
+
93
+ The OpenCode config loads Agency's hidden instructions in addition to any
94
+ user-owned root `AGENTS.md` and advertises the complete workbase as one portable
95
+ relative reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and
96
+ injects runtime-only absolute rules that allow external access across the
97
+ workbase while denying edits outside the execution unit's writable checkout.
98
+ This makes the config effective from nested task and phase Git checkouts without
99
+ persisting a machine-specific path or granting access outside the workbase. Bash
100
+ and Agency operations must still follow the write authority reported by
101
+ `agency context`.
92
102
 
93
103
  Repository aliases and canonical fetch remotes are declared in tracked
94
104
  `agency.json`; local bare clones and symlinks remain ignored under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.35.0",
3
+ "version": "2.37.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -114,7 +114,7 @@ status: open
114
114
  test("reports repository, ref, remote, and managed-file failures", async () => {
115
115
  await Bun.$`git -C ${repository} remote remove origin`
116
116
  await Bun.$`git -C ${repository} branch -m other`
117
- await write(root, "AGENTS.md", managedAgents("old\n"))
117
+ await write(root, ".agency/AGENTS.md", managedAgents("old\n"))
118
118
 
119
119
  const logs = await captureLogs(() =>
120
120
  runTestEffect(doctor({ cwd: root, json: true })),
@@ -153,7 +153,7 @@ status: open
153
153
  expect(check).toMatchObject({
154
154
  level: "warning",
155
155
  status: "fail",
156
- message: expect.stringContaining("cannot guarantee whole-workbase"),
156
+ message: expect.stringContaining("cannot guarantee its instructions"),
157
157
  remediation: expect.stringContaining("global config"),
158
158
  })
159
159
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
@@ -181,6 +181,7 @@ status: open
181
181
  }
182
182
 
183
183
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
184
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
184
185
  expect(
185
186
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
186
187
  ).toBe(false)
@@ -33,11 +33,13 @@ describe("init command", () => {
33
33
  expect(await Bun.file(join(root, ".gitignore")).text()).toBe(
34
34
  "/repos/\n/tasks/*/code/\n/tasks/*/phases/*/code/\n",
35
35
  )
36
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
36
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
37
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
37
38
  const opencode = await Bun.file(
38
39
  join(root, ".opencode/opencode.jsonc"),
39
40
  ).text()
40
41
  const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
42
+ expect(config.instructions).toEqual([".agency/AGENTS.md"])
41
43
  expect(config.references).toEqual({
42
44
  workbase: expect.objectContaining({ path: ".." }),
43
45
  })
@@ -50,7 +50,7 @@ describe("integration command", () => {
50
50
  runTestEffect(integration({ subcommand: "status", cwd: root })),
51
51
  )
52
52
 
53
- expect(logs.join("\n")).toContain("cannot guarantee whole-workbase")
53
+ expect(logs.join("\n")).toContain("cannot guarantee its instructions")
54
54
  expect(logs.join("\n")).toContain("global config")
55
55
  })
56
56
 
@@ -63,7 +63,8 @@ describe("integration command", () => {
63
63
  { name: "agents", state: "managed", changed: true },
64
64
  { name: "opencode", state: "managed", changed: true },
65
65
  ])
66
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
66
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
67
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
67
68
  expect(
68
69
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
69
70
  ).toBe(true)
@@ -9,6 +9,7 @@ import {
9
9
  rmdir,
10
10
  stat,
11
11
  symlink,
12
+ unlink,
12
13
  } from "node:fs/promises"
13
14
  import { spawnProcess } from "../utils/process"
14
15
 
@@ -117,6 +118,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
117
118
  }),
118
119
  }),
119
120
 
121
+ deleteFile: (path: string) =>
122
+ Effect.tryPromise({
123
+ try: () => unlink(path),
124
+ catch: (error) =>
125
+ new FileSystemError({
126
+ message: `Failed to delete file: ${path}`,
127
+ cause: error,
128
+ }),
129
+ }),
130
+
120
131
  createDirectory: (path: string) =>
121
132
  Effect.tryPromise({
122
133
  try: () => mkdir(path, { recursive: true }),
@@ -47,9 +47,9 @@ describe("IntegrationService", () => {
47
47
  "missing",
48
48
  "missing",
49
49
  ])
50
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
50
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
51
51
 
52
- await write(root, "AGENTS.md", managedWorkbaseAgents)
52
+ await write(root, ".agency/AGENTS.md", managedWorkbaseAgents)
53
53
  await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
54
54
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
55
55
  "managed",
@@ -58,7 +58,7 @@ describe("IntegrationService", () => {
58
58
  })
59
59
 
60
60
  test("reports customized and checksum-safe drifted files", async () => {
61
- await write(root, "AGENTS.md", "# Custom instructions\n")
61
+ await write(root, ".agency/AGENTS.md", "# Custom instructions\n")
62
62
  await write(
63
63
  root,
64
64
  ".opencode/opencode.jsonc",
@@ -97,6 +97,7 @@ describe("IntegrationService", () => {
97
97
  test("grants OpenCode access to the complete workbase", () => {
98
98
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
99
99
 
100
+ expect(config.instructions).toEqual([".agency/AGENTS.md"])
100
101
  expect(config.references).toEqual({
101
102
  workbase: {
102
103
  path: "..",
@@ -151,8 +152,10 @@ describe("IntegrationService", () => {
151
152
  })
152
153
 
153
154
  test("syncs missing and drifted files while preserving customized files", async () => {
154
- const customAgents = "# Custom instructions\n"
155
- await write(root, "AGENTS.md", customAgents)
155
+ const customRootAgents = "# User-owned workbase instructions\n"
156
+ const customManagedAgents = "# Customized Agency instructions\n"
157
+ await write(root, "AGENTS.md", customRootAgents)
158
+ await write(root, ".agency/AGENTS.md", customManagedAgents)
156
159
  await write(
157
160
  root,
158
161
  ".opencode/opencode.jsonc",
@@ -164,14 +167,50 @@ describe("IntegrationService", () => {
164
167
  { name: "agents", state: "customized", changed: false },
165
168
  { name: "opencode", state: "managed", changed: true },
166
169
  ])
167
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(customAgents)
170
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
171
+ customRootAgents,
172
+ )
173
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
174
+ customManagedAgents,
175
+ )
168
176
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
169
177
  managedWorkbaseOpencode,
170
178
  )
171
179
 
172
- await unlink(join(root, "AGENTS.md"))
180
+ await unlink(join(root, ".agency/AGENTS.md"))
173
181
  const second = await sync(root)
174
182
  expect(second.files[0]).toMatchObject({ state: "managed", changed: true })
183
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
184
+ managedWorkbaseAgents,
185
+ )
186
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
187
+ customRootAgents,
188
+ )
189
+ })
190
+
191
+ test("migrates checksum-valid root instructions", async () => {
192
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
193
+
194
+ const result = await sync(root)
195
+
196
+ expect(result.files[0]).toMatchObject({ state: "managed", changed: true })
197
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
198
+ managedWorkbaseAgents,
199
+ )
200
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
201
+ })
202
+
203
+ test("preserves legacy instructions when the OpenCode config is customized", async () => {
204
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
205
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
206
+
207
+ const result = await sync(root)
208
+
209
+ expect(result.files[0]).toMatchObject({ state: "managed", changed: true })
210
+ expect(result.files[1]).toMatchObject({
211
+ state: "customized",
212
+ changed: false,
213
+ })
175
214
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
176
215
  managedWorkbaseAgents,
177
216
  )
@@ -198,14 +237,16 @@ describe("IntegrationService", () => {
198
237
  "# Previous Agency instructions\n",
199
238
  " -->",
200
239
  )}User edit\n`
201
- await write(root, "AGENTS.md", tampered)
240
+ await write(root, ".agency/AGENTS.md", tampered)
202
241
 
203
242
  const result = await sync(root)
204
243
  expect(result.files[0]).toMatchObject({
205
244
  state: "customized",
206
245
  changed: false,
207
246
  })
208
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(tampered)
247
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
248
+ tampered,
249
+ )
209
250
  })
210
251
 
211
252
  test("does not overwrite a user-modified OpenCode configuration", async () => {
@@ -228,7 +269,8 @@ describe("IntegrationService", () => {
228
269
  test("does not follow symlinked integration files", async () => {
229
270
  const target = join(root, "custom-agents.md")
230
271
  await Bun.write(target, "# External instructions\n")
231
- await symlink(target, join(root, "AGENTS.md"))
272
+ await mkdir(join(root, ".agency"), { recursive: true })
273
+ await symlink(target, join(root, ".agency/AGENTS.md"))
232
274
 
233
275
  const result = await sync(root)
234
276
  expect(result.files[0]).toMatchObject({
@@ -33,23 +33,23 @@ const describe = (
33
33
  if (state === "managed") {
34
34
  return {
35
35
  diagnostic:
36
- "Agency's managed OpenCode launch config is ready to provide whole-workbase read access.",
36
+ "Agency's managed OpenCode launch config is ready to load Agency instructions and provide whole-workbase read access.",
37
37
  remediation: null,
38
38
  }
39
39
  }
40
40
  if (state === "customized") {
41
41
  return {
42
42
  diagnostic:
43
- "Agency cannot guarantee whole-workbase read access from this customized OpenCode config.",
43
+ "Agency cannot guarantee its instructions or whole-workbase read access from this customized OpenCode config.",
44
44
  remediation:
45
45
  "Back up and remove the customized file, run 'agency integration sync', then move any retained custom settings to OpenCode's global config.",
46
46
  }
47
47
  }
48
48
  return {
49
49
  diagnostic:
50
- "Agency OpenCode launches cannot load current whole-workbase access.",
50
+ "Agency OpenCode launches cannot load current Agency instructions or whole-workbase access.",
51
51
  remediation:
52
- "Run 'agency integration sync' to install whole-workbase OpenCode access.",
52
+ "Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.",
53
53
  }
54
54
  }
55
55
 
@@ -94,7 +94,7 @@ const classify = (
94
94
  const inspect = (root: string) =>
95
95
  Effect.gen(function* () {
96
96
  const fs = yield* FileSystemService
97
- const agentsPath = join(root, "AGENTS.md")
97
+ const agentsPath = join(root, ".agency", "AGENTS.md")
98
98
  const opencodeDirectory = join(root, ".opencode")
99
99
  const opencodePath = join(opencodeDirectory, "opencode.jsonc")
100
100
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
@@ -138,6 +138,18 @@ const inspect = (root: string) =>
138
138
  return files
139
139
  })
140
140
 
141
+ const canRemoveLegacyAgents = (root: string) =>
142
+ Effect.gen(function* () {
143
+ const fs = yield* FileSystemService
144
+ const path = join(root, "AGENTS.md")
145
+ if (
146
+ (yield* fs.readSymlinkTarget(path)) !== null ||
147
+ !(yield* fs.exists(path))
148
+ )
149
+ return false
150
+ return canUpdateManagedWorkbaseAgents(yield* fs.readFile(path))
151
+ })
152
+
141
153
  export class IntegrationService extends Effect.Service<IntegrationService>()(
142
154
  "IntegrationService",
143
155
  {
@@ -155,13 +167,19 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
155
167
  const workbase = yield* WorkbaseService
156
168
  const root = yield* workbase.discover(startPath)
157
169
  const statuses = yield* inspect(root)
170
+ const removeLegacyAgents =
171
+ statuses.some(
172
+ (status) =>
173
+ status.name === "opencode" && status.state !== "customized",
174
+ ) && (yield* canRemoveLegacyAgents(root))
158
175
  const files: IntegrationSyncFile[] = []
159
176
 
160
177
  for (const status of statuses) {
161
- const changed =
178
+ const needsWrite =
162
179
  status.state === "missing" || status.state === "drifted"
163
- if (changed) {
180
+ if (needsWrite) {
164
181
  if (status.name === "agents") {
182
+ yield* fs.createDirectory(join(root, ".agency"))
165
183
  yield* fs.writeFile(status.path, managedWorkbaseAgents)
166
184
  } else {
167
185
  yield* fs.createDirectory(join(root, ".opencode"))
@@ -172,12 +190,15 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
172
190
  ...fileStatus(
173
191
  status.name,
174
192
  status.path,
175
- changed ? "managed" : status.state,
193
+ needsWrite ? "managed" : status.state,
176
194
  ),
177
- changed,
195
+ changed:
196
+ needsWrite || (status.name === "agents" && removeLegacyAgents),
178
197
  })
179
198
  }
180
199
 
200
+ if (removeLegacyAgents) yield* fs.deleteFile(join(root, "AGENTS.md"))
201
+
181
202
  return { root, files }
182
203
  }),
183
204
  }),
@@ -0,0 +1,128 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import { cleanupTempDir, createTempDir } from "../test-utils"
4
+
5
+ const projectRoot = join(import.meta.dir, "../..")
6
+ const cliPath = join(projectRoot, "cli.ts")
7
+ const tempDirs: string[] = []
8
+
9
+ afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
10
+
11
+ const modes = (terminal: Bun.Terminal) => ({
12
+ input: terminal.inputFlags,
13
+ output: terminal.outputFlags,
14
+ local: terminal.localFlags,
15
+ control: terminal.controlFlags,
16
+ })
17
+
18
+ const waitFor = async (condition: () => boolean, output: () => string) => {
19
+ const deadline = Date.now() + 8_000
20
+ while (!condition()) {
21
+ if (Date.now() >= deadline) {
22
+ throw new Error(`Timed out waiting for terminal output:\n${output()}`)
23
+ }
24
+ await Bun.sleep(10)
25
+ }
26
+ }
27
+
28
+ const waitForExit = (subprocess: Bun.Subprocess, output: () => string) =>
29
+ new Promise<number>((resolve, reject) => {
30
+ const timeout = setTimeout(() => {
31
+ reject(new Error(`Timed out waiting for CLI exit:\n${output()}`))
32
+ }, 8_000)
33
+ subprocess.exited.then(
34
+ (exitCode) => {
35
+ clearTimeout(timeout)
36
+ resolve(exitCode)
37
+ },
38
+ (error) => {
39
+ clearTimeout(timeout)
40
+ reject(error)
41
+ },
42
+ )
43
+ })
44
+
45
+ const createWorkbase = async () => {
46
+ const root = await createTempDir()
47
+ tempDirs.push(root)
48
+ const initialized = Bun.spawnSync(
49
+ [process.execPath, cliPath, "init", root, "--silent"],
50
+ { stdout: "pipe", stderr: "pipe" },
51
+ )
52
+ if (initialized.exitCode !== 0) {
53
+ throw new Error(new TextDecoder().decode(initialized.stderr))
54
+ }
55
+ return root
56
+ }
57
+
58
+ const runPrompt = async (
59
+ drive: (terminal: Bun.Terminal, output: () => string) => Promise<void>,
60
+ ) => {
61
+ const root = await createWorkbase()
62
+ const decoder = new TextDecoder()
63
+ let output = ""
64
+ const terminal = new Bun.Terminal({
65
+ cols: 80,
66
+ rows: 24,
67
+ data: (_terminal, bytes) => {
68
+ output += decoder.decode(bytes, { stream: true })
69
+ },
70
+ })
71
+ const initialModes = modes(terminal)
72
+ const subprocess = Bun.spawn([process.execPath, cliPath, "task", "new"], {
73
+ cwd: root,
74
+ env: { ...process.env, TERM: "xterm-256color" },
75
+ terminal,
76
+ })
77
+
78
+ try {
79
+ await waitFor(
80
+ () => output.includes("Task ID:"),
81
+ () => output,
82
+ )
83
+ const activeModes = modes(terminal)
84
+ expect(activeModes).not.toEqual(initialModes)
85
+ await drive(terminal, () => output)
86
+ const exitCode = await waitForExit(subprocess, () => output)
87
+ output += decoder.decode()
88
+ expect(exitCode).toBe(1)
89
+ expect(modes(terminal)).toEqual(initialModes)
90
+ expect(output.lastIndexOf("\x1b[?25h")).toBeGreaterThan(
91
+ output.lastIndexOf("\x1b[?25l"),
92
+ )
93
+ expect(output.lastIndexOf("\x1b[?2004l")).toBeGreaterThan(
94
+ output.lastIndexOf("\x1b[?2004h"),
95
+ )
96
+ return output
97
+ } finally {
98
+ if (subprocess.exitCode === null) {
99
+ subprocess.kill("SIGKILL")
100
+ await subprocess.exited
101
+ }
102
+ terminal.close()
103
+ }
104
+ }
105
+
106
+ describe("interactive CLI terminal restoration", () => {
107
+ test("restores terminal state after submission, resize, and escape", async () => {
108
+ const output = await runPrompt(async (terminal, currentOutput) => {
109
+ terminal.resize(30, 8)
110
+ terminal.write("pty-contract\r")
111
+ await waitFor(
112
+ () => currentOutput().includes("Ticket URL (optional):"),
113
+ currentOutput,
114
+ )
115
+ terminal.write("\x1b")
116
+ })
117
+
118
+ expect(output).toContain("Failed to read task input")
119
+ }, 12_000)
120
+
121
+ test("restores terminal state after ctrl-c", async () => {
122
+ const output = await runPrompt(async (terminal) => {
123
+ terminal.write("\x03")
124
+ })
125
+
126
+ expect(output).toContain("Failed to read task input")
127
+ }, 12_000)
128
+ })
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test"
2
+ import { KeyCodes, type MockInput } from "@opentui/core/testing"
2
3
  import { testRender } from "@opentui/solid"
3
4
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4
5
  import { tmpdir } from "node:os"
@@ -10,6 +11,34 @@ import {
10
11
  interactiveRendererConfig,
11
12
  } from "./interactive"
12
13
 
14
+ const submitEditedText = async (
15
+ edit: (input: MockInput) => void | Promise<void>,
16
+ ) => {
17
+ let submitted: string | null | undefined
18
+ const setup = await testRender(
19
+ () => (
20
+ <InteractiveTextPrompt
21
+ prompt="Text"
22
+ onDone={(value) => {
23
+ submitted = value
24
+ }}
25
+ />
26
+ ),
27
+ { width: 60, height: 4 },
28
+ )
29
+ try {
30
+ await setup.renderer.setupTerminal()
31
+ await setup.renderOnce()
32
+ await Bun.sleep(0)
33
+ await edit(setup.mockInput)
34
+ setup.mockInput.pressEnter()
35
+ await setup.waitFor(() => submitted !== undefined)
36
+ return submitted
37
+ } finally {
38
+ setup.renderer.destroy()
39
+ }
40
+ }
41
+
13
42
  describe("OpenTUI interaction", () => {
14
43
  test("selects the Solid JSX runtime without the project preload", async () => {
15
44
  const source = await Bun.file(
@@ -109,8 +138,8 @@ describe("OpenTUI interaction", () => {
109
138
  expect(await selectAfter("down")).toBe("web")
110
139
  })
111
140
 
112
- test("uses printable j, k, and q characters as the fuzzy query", async () => {
113
- for (const query of ["j", "k", "q"]) {
141
+ test("uses printable j, k, q, and ? characters as the fuzzy query", async () => {
142
+ for (const query of ["j", "k", "q", "?"]) {
114
143
  let selected: string | null | undefined
115
144
  const setup = await testRender(
116
145
  () => (
@@ -141,6 +170,163 @@ describe("OpenTUI interaction", () => {
141
170
  }
142
171
  })
143
172
 
173
+ test("supports the readline movement and deletion contract", async () => {
174
+ const cases = [
175
+ {
176
+ name: "left arrow",
177
+ expected: "aXb",
178
+ edit: async (input: MockInput) => {
179
+ await input.typeText("ab")
180
+ input.pressArrow("left")
181
+ await input.typeText("X")
182
+ },
183
+ },
184
+ {
185
+ name: "home and end",
186
+ expected: "XabY",
187
+ edit: async (input: MockInput) => {
188
+ await input.typeText("ab")
189
+ input.pressKey(KeyCodes.HOME)
190
+ await input.typeText("X")
191
+ input.pressKey(KeyCodes.END)
192
+ await input.typeText("Y")
193
+ },
194
+ },
195
+ {
196
+ name: "backspace and delete",
197
+ expected: "a",
198
+ edit: async (input: MockInput) => {
199
+ await input.typeText("abc")
200
+ input.pressBackspace()
201
+ input.pressArrow("left")
202
+ input.pressKey(KeyCodes.DELETE)
203
+ },
204
+ },
205
+ {
206
+ name: "ctrl-a and ctrl-e",
207
+ expected: "XabY",
208
+ edit: async (input: MockInput) => {
209
+ await input.typeText("ab")
210
+ input.pressKey("a", { ctrl: true })
211
+ await input.typeText("X")
212
+ input.pressKey("e", { ctrl: true })
213
+ await input.typeText("Y")
214
+ },
215
+ },
216
+ {
217
+ name: "ctrl-b and ctrl-f",
218
+ expected: "aXb",
219
+ edit: async (input: MockInput) => {
220
+ await input.typeText("ab")
221
+ input.pressKey("b", { ctrl: true })
222
+ input.pressKey("b", { ctrl: true })
223
+ input.pressKey("f", { ctrl: true })
224
+ await input.typeText("X")
225
+ },
226
+ },
227
+ {
228
+ name: "meta-b and meta-f",
229
+ expected: "one Xtwo",
230
+ edit: async (input: MockInput) => {
231
+ await input.typeText("one two")
232
+ input.pressKey("b", { meta: true })
233
+ input.pressKey("b", { meta: true })
234
+ input.pressKey("f", { meta: true })
235
+ await input.typeText("X")
236
+ },
237
+ },
238
+ {
239
+ name: "ctrl-u",
240
+ expected: "c",
241
+ edit: async (input: MockInput) => {
242
+ await input.typeText("abc")
243
+ input.pressArrow("left")
244
+ input.pressKey("u", { ctrl: true })
245
+ },
246
+ },
247
+ {
248
+ name: "ctrl-k",
249
+ expected: "ab",
250
+ edit: async (input: MockInput) => {
251
+ await input.typeText("abc")
252
+ input.pressArrow("left")
253
+ input.pressKey("k", { ctrl: true })
254
+ },
255
+ },
256
+ {
257
+ name: "ctrl-w",
258
+ expected: "one ",
259
+ edit: async (input: MockInput) => {
260
+ await input.typeText("one two")
261
+ input.pressKey("w", { ctrl: true })
262
+ },
263
+ },
264
+ {
265
+ name: "ctrl-d",
266
+ expected: "ab",
267
+ edit: async (input: MockInput) => {
268
+ await input.typeText("abc")
269
+ input.pressArrow("left")
270
+ input.pressKey("d", { ctrl: true })
271
+ },
272
+ },
273
+ {
274
+ name: "ctrl-h",
275
+ expected: "ab",
276
+ edit: async (input: MockInput) => {
277
+ await input.typeText("abc")
278
+ input.pressKey("h", { ctrl: true })
279
+ },
280
+ },
281
+ ]
282
+
283
+ for (const contract of cases) {
284
+ expect(await submitEditedText(contract.edit), contract.name).toBe(
285
+ contract.expected,
286
+ )
287
+ }
288
+ })
289
+
290
+ test("yanks the most recently killed text in both prompt inputs", async () => {
291
+ expect(
292
+ await submitEditedText(async (input) => {
293
+ await input.typeText("one two")
294
+ input.pressKey("w", { ctrl: true })
295
+ input.pressKey("y", { ctrl: true })
296
+ }),
297
+ ).toBe("one two")
298
+
299
+ let selected: string | null | undefined
300
+ const setup = await testRender(
301
+ () => (
302
+ <InteractiveSelectPrompt
303
+ prompt="Repository"
304
+ choices={[
305
+ { key: "agency", label: "agency" },
306
+ { key: "web", label: "web" },
307
+ ]}
308
+ onDone={(value) => {
309
+ selected = value
310
+ }}
311
+ />
312
+ ),
313
+ { width: 60, height: 4 },
314
+ )
315
+ try {
316
+ await setup.renderer.setupTerminal()
317
+ await setup.renderOnce()
318
+ await Bun.sleep(0)
319
+ await setup.mockInput.typeText("web")
320
+ setup.mockInput.pressKey("u", { ctrl: true })
321
+ setup.mockInput.pressKey("y", { ctrl: true })
322
+ setup.mockInput.pressEnter()
323
+ await setup.waitFor(() => selected !== undefined)
324
+ expect(selected).toBe("web")
325
+ } finally {
326
+ setup.renderer.destroy()
327
+ }
328
+ })
329
+
144
330
  test("does not select an empty result and supports ctrl-u editing", async () => {
145
331
  let selected: string | null | undefined
146
332
  const setup = await testRender(
@@ -232,6 +418,98 @@ describe("OpenTUI interaction", () => {
232
418
  }
233
419
  })
234
420
 
421
+ test("keeps empty and single-item list boundaries safe", async () => {
422
+ let emptyResult: string | null | undefined
423
+ const empty = await testRender(
424
+ () => (
425
+ <InteractiveSelectPrompt
426
+ prompt="Empty"
427
+ choices={[]}
428
+ onDone={(value) => {
429
+ emptyResult = value
430
+ }}
431
+ />
432
+ ),
433
+ { width: 60, height: 4 },
434
+ )
435
+ try {
436
+ await empty.renderer.setupTerminal()
437
+ await empty.renderOnce()
438
+ await Bun.sleep(0)
439
+ empty.mockInput.pressArrow("up")
440
+ empty.mockInput.pressArrow("down")
441
+ empty.mockInput.pressKey("p", { ctrl: true })
442
+ empty.mockInput.pressKey("n", { ctrl: true })
443
+ empty.mockInput.pressEnter()
444
+ await Bun.sleep(0)
445
+ expect(emptyResult).toBeUndefined()
446
+ expect(empty.captureCharFrame()).toContain("No matches")
447
+ } finally {
448
+ empty.renderer.destroy()
449
+ }
450
+
451
+ for (const move of ["up", "down"] as const) {
452
+ let singleResult: string | null | undefined
453
+ const single = await testRender(
454
+ () => (
455
+ <InteractiveSelectPrompt
456
+ prompt="Single"
457
+ choices={[{ key: "one", label: "One" }]}
458
+ onDone={(value) => {
459
+ singleResult = value
460
+ }}
461
+ />
462
+ ),
463
+ { width: 60, height: 4 },
464
+ )
465
+ try {
466
+ await single.renderer.setupTerminal()
467
+ await single.renderOnce()
468
+ await Bun.sleep(0)
469
+ single.mockInput.pressArrow(move)
470
+ single.mockInput.pressEnter()
471
+ await single.waitFor(() => singleResult !== undefined)
472
+ expect(singleResult).toBe("one")
473
+ } finally {
474
+ single.renderer.destroy()
475
+ }
476
+ }
477
+ })
478
+
479
+ test("remains usable after a narrow terminal resize", async () => {
480
+ let selected: string | null | undefined
481
+ const setup = await testRender(
482
+ () => (
483
+ <InteractiveSelectPrompt
484
+ prompt="Repository with a long prompt"
485
+ choices={[
486
+ { key: "agency", label: "agency" },
487
+ { key: "docs", label: "docs" },
488
+ ]}
489
+ onDone={(value) => {
490
+ selected = value
491
+ }}
492
+ />
493
+ ),
494
+ { width: 60, height: 4 },
495
+ )
496
+ try {
497
+ await setup.renderer.setupTerminal()
498
+ await setup.renderOnce()
499
+ await Bun.sleep(0)
500
+ setup.resize(18, 4)
501
+ await setup.renderOnce()
502
+ await setup.mockInput.typeText("docs")
503
+ await setup.renderOnce()
504
+ expect(setup.captureCharFrame()).toContain("> docs")
505
+ setup.mockInput.pressEnter()
506
+ await setup.waitFor(() => selected !== undefined)
507
+ expect(selected).toBe("docs")
508
+ } finally {
509
+ setup.renderer.destroy()
510
+ }
511
+ })
512
+
235
513
  test("submits text and cancels with ctrl-c or escape", async () => {
236
514
  let submitted: string | null | undefined
237
515
  const input = await testRender(
@@ -34,9 +34,77 @@ interface PromptProps<T> {
34
34
  const isCancel = (key: { name: string; ctrl: boolean }) =>
35
35
  key.name === "escape" || (key.ctrl && key.name === "c")
36
36
 
37
+ interface PromptKey {
38
+ readonly name: string
39
+ readonly ctrl: boolean
40
+ preventDefault(): void
41
+ stopPropagation(): void
42
+ }
43
+
44
+ const removedText = (before: string, after: string) => {
45
+ let start = 0
46
+ while (
47
+ start < before.length &&
48
+ start < after.length &&
49
+ before[start] === after[start]
50
+ ) {
51
+ start++
52
+ }
53
+
54
+ let end = 0
55
+ while (
56
+ before[before.length - end - 1] === after[after.length - end - 1] &&
57
+ end < before.length - start &&
58
+ end < after.length - start
59
+ ) {
60
+ end++
61
+ }
62
+
63
+ return before.slice(start, before.length - end)
64
+ }
65
+
66
+ const createReadlineEditing = (
67
+ getInput: () => TextareaRenderable | undefined,
68
+ onInput?: (value: string) => void,
69
+ ) => {
70
+ let value = ""
71
+ let killBuffer = ""
72
+ let beforeKill: string | undefined
73
+
74
+ return {
75
+ get value() {
76
+ return value
77
+ },
78
+ handleInput(next: string) {
79
+ if (beforeKill !== undefined) {
80
+ const killed = removedText(beforeKill, next)
81
+ if (killed) killBuffer = killed
82
+ beforeKill = undefined
83
+ }
84
+ value = next
85
+ onInput?.(next)
86
+ },
87
+ handleKey(key: PromptKey) {
88
+ const current = getInput()?.plainText
89
+ if (current !== undefined && current !== value) this.handleInput(current)
90
+ if (!key.ctrl) return false
91
+ if (key.name === "y") {
92
+ key.preventDefault()
93
+ key.stopPropagation()
94
+ if (killBuffer) getInput()?.insertText(killBuffer)
95
+ return true
96
+ }
97
+ if (key.name === "u" || key.name === "k" || key.name === "w") {
98
+ beforeKill = value
99
+ }
100
+ return false
101
+ },
102
+ }
103
+ }
104
+
37
105
  export const InteractiveTextPrompt = (props: PromptProps<string>) => {
38
106
  let input: TextareaRenderable | undefined
39
- let value = ""
107
+ const editing = createReadlineEditing(() => input)
40
108
  useKeyboard((key) => {
41
109
  if (isCancel(key)) {
42
110
  key.preventDefault()
@@ -44,10 +112,11 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
44
112
  props.onDone(null)
45
113
  return
46
114
  }
115
+ if (editing.handleKey(key)) return
47
116
  if (key.name !== "return") return
48
117
  key.preventDefault()
49
118
  key.stopPropagation()
50
- props.onDone(value)
119
+ props.onDone(editing.value)
51
120
  })
52
121
 
53
122
  return (
@@ -59,7 +128,7 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
59
128
  wrapMode="word"
60
129
  keyBindings={[{ name: "return", action: "submit" }]}
61
130
  onContentChange={() => {
62
- value = input?.plainText ?? ""
131
+ editing.handleInput(input?.plainText ?? "")
63
132
  }}
64
133
  ref={(next) => {
65
134
  input = next
@@ -68,7 +137,9 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
68
137
  })
69
138
  }}
70
139
  />
71
- <text fg="#6c7086">enter submit | esc cancel</text>
140
+ <text fg="#6c7086" wrapMode="none">
141
+ enter submit | esc cancel | ctrl-y yank
142
+ </text>
72
143
  </box>
73
144
  )
74
145
  }
@@ -145,6 +216,7 @@ export const fuzzyChoices = (
145
216
  export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
146
217
  let input: TextareaRenderable | undefined
147
218
  const [query, setQuery] = createSignal("")
219
+ const editing = createReadlineEditing(() => input, setQuery)
148
220
  const [selected, setSelected] = createSignal(0)
149
221
  const choices = createMemo(() => fuzzyChoices(props.choices, query()))
150
222
  const move = (offset: -1 | 1) => {
@@ -171,6 +243,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
171
243
  move(1)
172
244
  return
173
245
  }
246
+ if (editing.handleKey(key)) return
174
247
  if (key.name !== "return") return
175
248
  key.preventDefault()
176
249
  key.stopPropagation()
@@ -207,7 +280,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
207
280
  placeholder="filter"
208
281
  keyBindings={[{ name: "return", action: "submit" }]}
209
282
  onContentChange={() => {
210
- setQuery(input?.plainText ?? "")
283
+ editing.handleInput(input?.plainText ?? "")
211
284
  setSelected(0)
212
285
  }}
213
286
  ref={(next) => {
@@ -232,7 +305,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
232
305
  </For>
233
306
  </box>
234
307
  <text fg="#6c7086" wrapMode="none">
235
- enter select | esc cancel | ctrl-n/p or arrows
308
+ enter select | esc cancel | arrows/ctrl-n/p | ctrl-y yank
236
309
  </text>
237
310
  </box>
238
311
  )
@@ -67,9 +67,13 @@ a refinement loop, or pausing or handing off completed implementation work):
67
67
  ## Managed Integration
68
68
 
69
69
  `agency integration status` reports `managed`, `drifted`, `customized`, or
70
- `missing` generated files. `agency integration sync` updates only missing or
71
- checksum-safe drifted files and preserves user-customized files. `agency init`
72
- creates these files, and `agency work` reconciles them before launching an agent.
70
+ `missing` generated files. Agency keeps these instructions in
71
+ `.agency/AGENTS.md`, and its managed OpenCode config loads them automatically.
72
+ The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
73
+ owner and composes with these instructions through OpenCode's normal discovery.
74
+ `agency integration sync` updates only missing or checksum-safe drifted managed
75
+ files and preserves user-customized files. `agency init` creates the managed
76
+ files, and `agency work` reconciles them before launching an agent.
73
77
 
74
78
  OpenCode can access the complete workbase tree, but this filesystem permission
75
79
  does not expand Agency write authority beyond the checkout reported by
@@ -10,6 +10,7 @@ const body = () =>
10
10
  `${JSON.stringify(
11
11
  {
12
12
  $schema: "https://opencode.ai/config.json",
13
+ instructions: [".agency/AGENTS.md"],
13
14
  references: {
14
15
  workbase: {
15
16
  path: "..",