@markjaquith/agency 2.69.0 → 2.70.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
@@ -56,8 +56,6 @@ workbase/
56
56
  tui.jsonc # managed TUI plugin registration
57
57
  plugins/agency-repository-skills.ts # managed workbase access and checkout skills
58
58
  tui/agency-debug.ts # managed /agency-debug TUI diagnostic
59
- .pi/
60
- extensions/agency-workbase.ts # managed workbase context and checkout skills
61
59
  agency.json # tracked config and portable repository declarations
62
60
  repos/ # ignored local materializations
63
61
  frontend/ # bare Git repository or symlink
@@ -86,8 +84,8 @@ workbase/
86
84
 
87
85
  Agency keeps discovery and other observational commands read-only. Run
88
86
  `agency integration status` to inspect `.agency/AGENTS.md`, the managed
89
- OpenCode configuration and plugins, and `.pi/extensions/agency-workbase.ts`,
90
- then `agency integration sync` to create missing files or refresh checksum-safe
87
+ OpenCode configuration and plugins, then `agency integration sync` to create
88
+ missing files or refresh checksum-safe
91
89
  managed files. Customized files are reported but never overwritten. Sync also
92
90
  removes checksum-valid retired managed artifacts while
93
91
  preserving customized files at their former paths. The root
@@ -128,15 +126,15 @@ The plugin grants whole-workbase access dynamically, while the portable
128
126
  reference advertises that context to agents. Bash and Agency operations must
129
127
  still follow the write authority reported by `agency context`.
130
128
 
131
- Pi discovers the managed project extension automatically when launched from
132
- the workbase root after the project is trusted. When launching Pi from a task,
133
- phase, or epic directory, pass the managed file with `--extension`, for example
134
- `pi -e /path/to/workbase/.pi/extensions/agency-workbase.ts`. The extension loads
135
- the managed Agency instructions into Pi's system prompt, advertises the complete
136
- workbase, and exposes skills from the writable checkout's `.claude/skills`,
137
- `.agents/skills`, `.opencode/{skill,skills}`, and `.pi/skills` directories.
138
- Agency context still determines write authority; reference checkouts remain
139
- read-only.
129
+ Agency's package lifecycle installs one Pi extension at
130
+ `~/.pi/agent/extensions/agency.ts`. Pi loads it globally, so it works from
131
+ workbase roots and nested epic, task, phase, or checkout directories without
132
+ project trust or an explicit `--extension` flag. Outside an Agency workbase it
133
+ registers no resources and changes no prompt. Inside one, it loads the managed
134
+ Agency instructions, advertises the complete workbase, and exposes skills from
135
+ the writable checkout's `.claude/skills`, `.agents/skills`,
136
+ `.opencode/{skill,skills}`, and `.pi/skills` directories. `agency context`
137
+ remains the authority for writes; reference checkouts remain read-only.
140
138
 
141
139
  Repository aliases, the version-control backend, and canonical fetch remotes are
142
140
  declared in tracked `agency.json`; local clones and symlinks remain ignored under
@@ -434,11 +432,11 @@ working directory so the workbase `AGENTS.md` and managed OpenCode config are
434
432
  discovered normally.
435
433
  Agency's managed OpenCode plugin grants the active workbase external-directory
436
434
  access and adds existing checkout-local `.claude/skills`, `.agents/skills`, and
437
- `.opencode/{skill,skills}` directories to `skills.paths`. The managed Pi
435
+ `.opencode/{skill,skills}` directories to `skills.paths`. The global Pi
438
436
  extension provides equivalent whole-workbase context and additionally discovers
439
437
  checkout-local `.pi/skills` through Pi's `resources_discover` lifecycle.
440
- `agency work` supplies the checkout directly; plain OpenCode launches and Pi
441
- launches with the managed extension loaded resolve a materialized execution-unit
438
+ `agency work` supplies the checkout directly; plain OpenCode and Pi launches
439
+ resolve a materialized execution-unit
442
440
  checkout through `agency context`. A multi-phase
443
441
  task root has no single checkout, so launch from its phase directory when using
444
442
  plain OpenCode or Pi. Other checkout-local configuration is not composed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.69.0",
3
+ "version": "2.70.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -22,6 +22,8 @@
22
22
  "cli.ts",
23
23
  "cli-main.ts",
24
24
  "src",
25
+ "pi-extensions",
26
+ "scripts/install-pi-extension.ts",
25
27
  "schemas",
26
28
  "fixtures/protocol",
27
29
  "README.md",
@@ -61,6 +63,8 @@
61
63
  "tag": "latest"
62
64
  },
63
65
  "scripts": {
66
+ "postinstall": "bun scripts/install-pi-extension.ts install",
67
+ "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
64
68
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
65
69
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
66
70
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
@@ -0,0 +1,117 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir } from "../src/test-utils"
5
+ import agencyExtension from "./agency"
6
+
7
+ describe("global Pi extension", () => {
8
+ let root: string
9
+
10
+ beforeEach(async () => {
11
+ root = await createTempDir()
12
+ })
13
+
14
+ afterEach(async () => cleanupTempDir(root))
15
+
16
+ const load = (response?: object) => {
17
+ const handlers = new Map<string, (...args: any[]) => any>()
18
+ const pi = {
19
+ exec: async () => ({
20
+ code: response ? 0 : 1,
21
+ stdout: response ? JSON.stringify(response) : "",
22
+ stderr: "",
23
+ }),
24
+ on: (name: string, handler: (...args: any[]) => any) => {
25
+ handlers.set(name, handler)
26
+ },
27
+ }
28
+ agencyExtension(pi as never)
29
+ return handlers
30
+ }
31
+
32
+ test("no-ops outside Agency workbases without invoking the CLI", async () => {
33
+ let executions = 0
34
+ const handlers = new Map<string, (...args: any[]) => any>()
35
+ agencyExtension({
36
+ exec: async () => {
37
+ executions += 1
38
+ return { code: 1, stdout: "", stderr: "" }
39
+ },
40
+ on: (name: string, handler: (...args: any[]) => any) => {
41
+ handlers.set(name, handler)
42
+ },
43
+ } as never)
44
+
45
+ expect(
46
+ await handlers.get("resources_discover")?.({ cwd: root }),
47
+ ).toBeUndefined()
48
+ expect(
49
+ await handlers.get("before_agent_start")?.(
50
+ { systemPrompt: "Base" },
51
+ { cwd: root },
52
+ ),
53
+ ).toBeUndefined()
54
+ expect(executions).toBe(0)
55
+ })
56
+
57
+ test("uses CLI context from nested directories for skills and authority", async () => {
58
+ const task = join(root, "tasks", "example")
59
+ const nested = join(task, "code", "agency", "src")
60
+ const checkout = join(task, "code", "agency")
61
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
62
+ await mkdir(join(root, ".agency"), { recursive: true })
63
+ await Bun.write(
64
+ join(root, ".agency", "AGENTS.md"),
65
+ "# Managed instructions\n",
66
+ )
67
+ await mkdir(join(checkout, ".agents", "skills"), { recursive: true })
68
+ await mkdir(nested, { recursive: true })
69
+
70
+ const handlers = load({
71
+ ok: true,
72
+ result: {
73
+ workbase: { root },
74
+ target: {
75
+ kind: "task",
76
+ taskId: "example",
77
+ path: join(task, "TASK.md"),
78
+ },
79
+ authority: { mode: "execution", writable: { checkoutPath: checkout } },
80
+ documents: { task: { data: { status: "working" } } },
81
+ validation: { valid: true },
82
+ },
83
+ })
84
+ const resources = await handlers.get("resources_discover")?.({
85
+ cwd: nested,
86
+ })
87
+ expect(resources.skillPaths).toEqual([join(checkout, ".agents", "skills")])
88
+
89
+ const prompt = await handlers.get("before_agent_start")?.(
90
+ { systemPrompt: "Base" },
91
+ { cwd: nested },
92
+ )
93
+ expect(prompt.systemPrompt).toContain("# Managed instructions")
94
+ expect(prompt.systemPrompt).toContain(
95
+ `${checkout} as the default implementation directory`,
96
+ )
97
+ })
98
+
99
+ test("rejects CLI context for a different workbase", async () => {
100
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
101
+ const handlers = load({
102
+ ok: true,
103
+ result: {
104
+ workbase: { root: join(root, "other") },
105
+ target: { kind: "epic", epicId: "example" },
106
+ validation: { valid: true },
107
+ },
108
+ })
109
+
110
+ expect(
111
+ await handlers.get("before_agent_start")?.(
112
+ { systemPrompt: "Base" },
113
+ { cwd: root },
114
+ ),
115
+ ).toBeUndefined()
116
+ })
117
+ })
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+
4
+ type ExtensionAPI = {
5
+ exec: (
6
+ command: string,
7
+ args: string[],
8
+ options: { timeout: number },
9
+ ) => Promise<{ code: number; stdout: string }>
10
+ on: (
11
+ name: "resources_discover" | "before_agent_start",
12
+ handler: (...args: any[]) => unknown,
13
+ ) => void
14
+ }
15
+
16
+ type AgencyContext = {
17
+ root?: string
18
+ checkout?: string
19
+ target?: string
20
+ }
21
+
22
+ const contextTarget = (result: Record<string, any>): string | undefined => {
23
+ const target = result.target
24
+ if (target?.kind === "epic") return `epic:${target.epicId}`
25
+ if (target?.kind === "phase") {
26
+ return `execution-unit:phase/${target.taskId}/${target.phaseId}`
27
+ }
28
+ if (target?.kind === "task") {
29
+ return result.authority?.mode === "execution"
30
+ ? `execution-unit:task/${target.taskId}`
31
+ : `task:${target.taskId}`
32
+ }
33
+ }
34
+
35
+ const discoverWorkbase = (directory: string) => {
36
+ let current = resolve(directory)
37
+ while (true) {
38
+ if (existsSync(join(current, "agency.json"))) return current
39
+ const parent = dirname(current)
40
+ if (parent === current) return
41
+ current = parent
42
+ }
43
+ }
44
+
45
+ const agencyContext = async (
46
+ pi: ExtensionAPI,
47
+ directory: string,
48
+ ): Promise<AgencyContext | undefined> => {
49
+ const root = discoverWorkbase(directory)
50
+ if (!root) return
51
+
52
+ const command = await pi.exec(
53
+ "agency",
54
+ ["context", directory, "--compact", "--json"],
55
+ { timeout: 5000 },
56
+ )
57
+ if (command.code !== 0) return
58
+
59
+ const envelope = JSON.parse(command.stdout)
60
+ if (envelope.ok !== true) return
61
+ const result = envelope.result ?? {}
62
+ const target = contextTarget(result)
63
+ const status =
64
+ result.documents?.phase?.data?.status ??
65
+ result.documents?.task?.data?.status
66
+ if (
67
+ result.validation?.valid !== true ||
68
+ result.workbase?.root !== root ||
69
+ !target ||
70
+ (target.startsWith("execution-unit:") &&
71
+ (!result.authority?.writable?.checkoutPath || status !== "working"))
72
+ )
73
+ return
74
+
75
+ return {
76
+ root,
77
+ checkout: result.authority?.writable?.checkoutPath,
78
+ target,
79
+ }
80
+ }
81
+
82
+ export default function agencyExtension(pi: ExtensionAPI) {
83
+ const contexts = new Map<string, Promise<AgencyContext | undefined>>()
84
+ const runtimeContext = (directory: string) => {
85
+ const key = resolve(directory)
86
+ let context = contexts.get(key)
87
+ if (!context) {
88
+ context = agencyContext(pi, key).catch(() => undefined)
89
+ contexts.set(key, context)
90
+ }
91
+ return context
92
+ }
93
+
94
+ pi.on("resources_discover", async (event: { cwd: string }) => {
95
+ const context = await runtimeContext(event.cwd)
96
+ if (!context?.checkout) return
97
+
98
+ const skillPaths = [
99
+ join(context.checkout, ".claude", "skills"),
100
+ join(context.checkout, ".agents", "skills"),
101
+ join(context.checkout, ".opencode", "skill"),
102
+ join(context.checkout, ".opencode", "skills"),
103
+ join(context.checkout, ".pi", "skills"),
104
+ ].filter(existsSync)
105
+ if (skillPaths.length > 0) return { skillPaths: [...new Set(skillPaths)] }
106
+ })
107
+
108
+ pi.on(
109
+ "before_agent_start",
110
+ async (event: { systemPrompt: string }, ctx: { cwd: string }) => {
111
+ const context = await runtimeContext(ctx.cwd)
112
+ if (!context?.root) return
113
+
114
+ const instructionsPath = join(context.root, ".agency", "AGENTS.md")
115
+ const instructions = existsSync(instructionsPath)
116
+ ? readFileSync(instructionsPath, "utf8").trim()
117
+ : undefined
118
+ const activeTarget = process.env.AGENCY_TARGET
119
+ const worker =
120
+ activeTarget && context.target === activeTarget
121
+ ? `Agency verified this Pi session as the active worker for ${activeTarget}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.`
122
+ : undefined
123
+ const access = `The complete Agency workbase is available at ${context.root}. Use absolute paths under that root when workbase context is needed. Agency context remains the authority for writes.`
124
+ const implementation = context.checkout
125
+ ? `Pi remains rooted in the task or phase directory for Agency instructions and context. Treat ${context.checkout} as the default implementation directory for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.`
126
+ : undefined
127
+
128
+ return {
129
+ systemPrompt: [
130
+ event.systemPrompt,
131
+ instructions,
132
+ access,
133
+ worker,
134
+ implementation,
135
+ ]
136
+ .filter(Boolean)
137
+ .join("\n\n"),
138
+ }
139
+ },
140
+ )
141
+ }
@@ -0,0 +1,25 @@
1
+ import { cp, mkdir, rm } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { dirname, join } from "node:path"
4
+
5
+ export const piExtensionPath = (home = homedir()) =>
6
+ join(home, ".pi", "agent", "extensions", "agency.ts")
7
+
8
+ export const installPiExtension = async (
9
+ source = join(import.meta.dir, "..", "pi-extensions", "agency.ts"),
10
+ destination = piExtensionPath(),
11
+ ) => {
12
+ await mkdir(dirname(destination), { recursive: true })
13
+ await cp(source, destination)
14
+ }
15
+
16
+ export const uninstallPiExtension = async (destination = piExtensionPath()) => {
17
+ await rm(destination, { force: true })
18
+ }
19
+
20
+ if (import.meta.main) {
21
+ const command = process.argv[2] ?? "install"
22
+ if (command === "install") await installPiExtension()
23
+ else if (command === "uninstall") await uninstallPiExtension()
24
+ else throw new Error(`Unknown Pi extension lifecycle command: ${command}`)
25
+ }
package/src/cli.test.ts CHANGED
@@ -642,7 +642,6 @@ exit 23
642
642
  { name: "opencode-plugin", state: "managed" },
643
643
  { name: "opencode-tui", state: "managed" },
644
644
  { name: "opencode-tui-plugin", state: "managed" },
645
- { name: "pi-extension", state: "managed" },
646
645
  ])
647
646
 
648
647
  const synced = parseJson(
@@ -654,7 +653,6 @@ exit 23
654
653
  { name: "opencode-plugin", state: "managed", changed: false },
655
654
  { name: "opencode-tui", state: "managed", changed: false },
656
655
  { name: "opencode-tui-plugin", state: "managed", changed: false },
657
- { name: "pi-extension", state: "managed", changed: false },
658
656
  ])
659
657
  })
660
658
 
@@ -89,12 +89,9 @@ describe("init command", () => {
89
89
  expect(tuiPlugin).toContain('slashName: "agency-debug"')
90
90
  expect(tuiPlugin).toContain("api.ui.toast")
91
91
  expect(tuiPlugin).not.toContain("chat.message")
92
- const piExtension = await Bun.file(
93
- join(root, ".pi/extensions/agency-workbase.ts"),
94
- ).text()
95
- expect(piExtension).toContain('pi.on("resources_discover"')
96
- expect(piExtension).toContain('pi.on("before_agent_start"')
97
- expect(piExtension).toContain("AGENCY_WRITABLE_CHECKOUT")
92
+ expect(
93
+ await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).exists(),
94
+ ).toBe(false)
98
95
  })
99
96
 
100
97
  test("preserves existing gitignore entries", async () => {
@@ -51,11 +51,6 @@ describe("integration command", () => {
51
51
  state: "missing",
52
52
  remediation: expect.stringContaining("integration sync"),
53
53
  },
54
- {
55
- name: "pi-extension",
56
- state: "missing",
57
- remediation: expect.stringContaining("integration sync"),
58
- },
59
54
  ],
60
55
  })
61
56
  })
@@ -104,12 +99,7 @@ OpenCode TUI config: missing
104
99
  OpenCode /agency-debug: missing
105
100
  Path: .opencode/tui/agency-debug.ts
106
101
  The managed OpenCode TUI diagnostic companion needs synchronization.
107
- Action: Run 'agency integration sync' to install /agency-debug.
108
-
109
- Pi workbase extension: missing
110
- Path: .pi/extensions/agency-workbase.ts
111
- The managed Pi workbase extension needs synchronization.
112
- Action: Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.`)
102
+ Action: Run 'agency integration sync' to install /agency-debug.`)
113
103
  })
114
104
 
115
105
  test("explicitly synchronizes integration files", async () => {
@@ -123,7 +113,6 @@ Pi workbase extension: missing
123
113
  { name: "opencode-plugin", state: "managed", changed: true },
124
114
  { name: "opencode-tui", state: "managed", changed: true },
125
115
  { name: "opencode-tui-plugin", state: "managed", changed: true },
126
- { name: "pi-extension", state: "managed", changed: true },
127
116
  ])
128
117
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
129
118
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
@@ -144,9 +133,6 @@ Pi workbase extension: missing
144
133
  expect(
145
134
  await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).exists(),
146
135
  ).toBe(true)
147
- expect(
148
- await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).exists(),
149
- ).toBe(true)
150
136
  })
151
137
 
152
138
  test("formats integration sync results for people", async () => {
@@ -174,10 +160,6 @@ OpenCode TUI config: synced
174
160
 
175
161
  OpenCode /agency-debug: synced
176
162
  Path: .opencode/tui/agency-debug.ts
177
- Agency's managed OpenCode TUI diagnostic companion is current.
178
-
179
- Pi workbase extension: synced
180
- Path: .pi/extensions/agency-workbase.ts
181
- Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.`)
163
+ Agency's managed OpenCode TUI diagnostic companion is current.`)
182
164
  })
183
165
  })
@@ -18,7 +18,6 @@ interface IntegrationResult {
18
18
  | "opencode-plugin"
19
19
  | "opencode-tui"
20
20
  | "opencode-tui-plugin"
21
- | "pi-extension"
22
21
  readonly path: string
23
22
  readonly state: string
24
23
  readonly diagnostic: string
@@ -33,7 +32,6 @@ const integrationNames = {
33
32
  "opencode-plugin": "OpenCode workbase plugin",
34
33
  "opencode-tui": "OpenCode TUI config",
35
34
  "opencode-tui-plugin": "OpenCode /agency-debug",
36
- "pi-extension": "Pi workbase extension",
37
35
  } as const
38
36
 
39
37
  const logHumanResult = (
@@ -92,9 +90,9 @@ Usage: agency integration <subcommand>
92
90
 
93
91
  Inspect or explicitly synchronize managed agent integration files. OpenCode
94
92
  launches load the managed instructions and project config at runtime. Managed
95
- OpenCode and Pi extensions provide whole-workbase context and writable-checkout
96
- skills, and the /agency-debug TUI diagnostic reports OpenCode integration state
97
- without changing Agency write authority.
93
+ The managed OpenCode extension provides whole-workbase context and
94
+ writable-checkout skills, and the /agency-debug TUI diagnostic reports OpenCode
95
+ integration state without changing Agency write authority.
98
96
 
99
97
  Subcommands:
100
98
  status Report file state, access diagnostics, and safe remediation
@@ -16,10 +16,6 @@ import {
16
16
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
17
17
  managedWorkbaseOpencodeTuiPlugin,
18
18
  } from "../workbase/opencode-tui-plugin-file"
19
- import {
20
- canUpdateManagedWorkbasePiExtension,
21
- managedWorkbasePiExtension,
22
- } from "../workbase/pi-extension-file"
23
19
  import { IntegrationService } from "./IntegrationService"
24
20
 
25
21
  const write = async (root: string, path: string, content: string) => {
@@ -69,7 +65,6 @@ describe("IntegrationService", () => {
69
65
  "missing",
70
66
  "missing",
71
67
  "missing",
72
- "missing",
73
68
  ])
74
69
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
75
70
 
@@ -86,18 +81,12 @@ describe("IntegrationService", () => {
86
81
  ".opencode/tui/agency-debug.ts",
87
82
  managedWorkbaseOpencodeTuiPlugin,
88
83
  )
89
- await write(
90
- root,
91
- ".pi/extensions/agency-workbase.ts",
92
- managedWorkbasePiExtension,
93
- )
94
84
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
95
85
  "managed",
96
86
  "managed",
97
87
  "managed",
98
88
  "managed",
99
89
  "managed",
100
- "managed",
101
90
  ])
102
91
  })
103
92
 
@@ -115,7 +104,6 @@ describe("IntegrationService", () => {
115
104
  "missing",
116
105
  "missing",
117
106
  "missing",
118
- "missing",
119
107
  ])
120
108
  })
121
109
 
@@ -220,131 +208,6 @@ describe("IntegrationService", () => {
220
208
  ).toBe(false)
221
209
  })
222
210
 
223
- test("generates a Pi extension with workbase context and checkout skills", async () => {
224
- expect(managedWorkbasePiExtension).toContain(
225
- 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
226
- )
227
- expect(managedWorkbasePiExtension).toContain('pi.on("resources_discover"')
228
- expect(managedWorkbasePiExtension).toContain('pi.on("before_agent_start"')
229
- expect(managedWorkbasePiExtension).toContain(
230
- 'join(checkout, ".agents", "skills")',
231
- )
232
- expect(managedWorkbasePiExtension).toContain(
233
- 'join(checkout, CONFIG_DIR_NAME, "skills")',
234
- )
235
- expect(managedWorkbasePiExtension).toContain(
236
- "The complete Agency workbase is available",
237
- )
238
- expect(managedWorkbasePiExtension).toContain(
239
- "Agency context remains the authority for writes",
240
- )
241
- expect(
242
- canUpdateManagedWorkbasePiExtension(managedWorkbasePiExtension),
243
- ).toBe(true)
244
- expect(
245
- canUpdateManagedWorkbasePiExtension(
246
- managedWorkbasePiExtension.replace("resources_discover", "discover"),
247
- ),
248
- ).toBe(false)
249
-
250
- const taskDirectory = join(root, "tasks/example")
251
- const checkoutPath = join(taskDirectory, "code/agency")
252
- await write(root, ".agency/AGENTS.md", "# Managed Agency instructions\n")
253
- await write(
254
- root,
255
- "tasks/example/TASK.md",
256
- "---\nrepo: agency\nstatus: working\n---\n",
257
- )
258
- await write(
259
- root,
260
- "tasks/example/code/agency/.agents/skills/example/SKILL.md",
261
- "---\nname: example\ndescription: Example.\n---\n",
262
- )
263
- await write(
264
- root,
265
- "tasks/example/code/agency/.pi/skills/pi-example/SKILL.md",
266
- "---\nname: pi-example\ndescription: Pi example.\n---\n",
267
- )
268
- const path = join(root, ".pi/extensions/agency-workbase.ts")
269
- await write(
270
- root,
271
- ".pi/extensions/agency-workbase.ts",
272
- managedWorkbasePiExtension.replace(
273
- 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
274
- 'const CONFIG_DIR_NAME = ".pi"\ntype ExtensionAPI = any',
275
- ),
276
- )
277
-
278
- const contextResponse = JSON.stringify({
279
- version: 1,
280
- ok: true,
281
- result: {
282
- workbase: { root },
283
- target: {
284
- kind: "task",
285
- taskId: "example",
286
- path: join(taskDirectory, "TASK.md"),
287
- },
288
- authority: {
289
- mode: "execution",
290
- writable: { checkoutPath },
291
- },
292
- documents: { task: { data: { status: "working" } } },
293
- validation: { valid: true, warnings: [] },
294
- },
295
- })
296
- const handlers = new Map<string, (...args: any[]) => any>()
297
- const pi = {
298
- exec: async () => ({ code: 0, stdout: contextResponse, stderr: "" }),
299
- on: (name: string, handler: (...args: any[]) => any) => {
300
- handlers.set(name, handler)
301
- },
302
- }
303
- const previousTarget = process.env.AGENCY_TARGET
304
- const previousWorkbase = process.env.AGENCY_WORKBASE
305
- const previousCheckout = process.env.AGENCY_WRITABLE_CHECKOUT
306
- delete process.env.AGENCY_WORKBASE
307
- delete process.env.AGENCY_WRITABLE_CHECKOUT
308
- process.env.AGENCY_TARGET = "execution-unit:task/example"
309
- try {
310
- const generated = await import(
311
- `${pathToFileURL(path).href}?pi-extension=${Date.now()}`
312
- )
313
- generated.default(pi)
314
- const resources = await handlers.get("resources_discover")?.({
315
- cwd: taskDirectory,
316
- reason: "startup",
317
- })
318
- expect(resources.skillPaths).toEqual([
319
- join(checkoutPath, ".agents/skills"),
320
- join(checkoutPath, ".pi/skills"),
321
- ])
322
-
323
- const prompt = await handlers.get("before_agent_start")?.(
324
- { systemPrompt: "Base prompt" },
325
- { cwd: taskDirectory },
326
- )
327
- expect(prompt.systemPrompt).toContain("# Managed Agency instructions")
328
- expect(prompt.systemPrompt).toContain(
329
- `The complete Agency workbase is available at ${root}`,
330
- )
331
- expect(prompt.systemPrompt).toContain(
332
- "active worker for execution-unit:task/example",
333
- )
334
- expect(prompt.systemPrompt).toContain(
335
- `${checkoutPath} as the default implementation directory`,
336
- )
337
- } finally {
338
- if (previousTarget === undefined) delete process.env.AGENCY_TARGET
339
- else process.env.AGENCY_TARGET = previousTarget
340
- if (previousWorkbase === undefined) delete process.env.AGENCY_WORKBASE
341
- else process.env.AGENCY_WORKBASE = previousWorkbase
342
- if (previousCheckout === undefined)
343
- delete process.env.AGENCY_WRITABLE_CHECKOUT
344
- else process.env.AGENCY_WRITABLE_CHECKOUT = previousCheckout
345
- }
346
- })
347
-
348
211
  const testWorkerIdentity = async ({
349
212
  target,
350
213
  launchTarget,
@@ -870,7 +733,6 @@ describe("IntegrationService", () => {
870
733
  { name: "opencode-plugin", state: "managed", changed: true },
871
734
  { name: "opencode-tui", state: "managed", changed: true },
872
735
  { name: "opencode-tui-plugin", state: "managed", changed: true },
873
- { name: "pi-extension", state: "managed", changed: true },
874
736
  ])
875
737
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
876
738
  customRootAgents,
@@ -895,9 +757,6 @@ describe("IntegrationService", () => {
895
757
  expect(
896
758
  await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).text(),
897
759
  ).toBe(managedWorkbaseOpencodeTuiPlugin)
898
- expect(
899
- await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
900
- ).toBe(managedWorkbasePiExtension)
901
760
 
902
761
  await unlink(join(root, ".agency/AGENTS.md"))
903
762
  const second = await sync(root)
@@ -962,6 +821,30 @@ describe("IntegrationService", () => {
962
821
  })
963
822
  })
964
823
 
824
+ test("removes a checksum-valid workbase-local Pi extension", async () => {
825
+ const body = "export default function legacyAgencyExtension() {}\n"
826
+ const checksum = createHash("sha256").update(body).digest("hex")
827
+ const path = join(root, ".pi/extensions/agency-workbase.ts")
828
+ await write(
829
+ root,
830
+ ".pi/extensions/agency-workbase.ts",
831
+ `// agency-managed: sha256=${checksum}\n\n${body}`,
832
+ )
833
+
834
+ await sync(root)
835
+ expect(await Bun.file(path).exists()).toBe(false)
836
+ expect(await Bun.file(join(root, ".pi")).exists()).toBe(false)
837
+ })
838
+
839
+ test("preserves a customized workbase-local Pi extension", async () => {
840
+ const custom = "export default function customPiExtension() {}\n"
841
+ const path = join(root, ".pi/extensions/agency-workbase.ts")
842
+ await write(root, ".pi/extensions/agency-workbase.ts", custom)
843
+
844
+ await sync(root)
845
+ expect(await Bun.file(path).text()).toBe(custom)
846
+ })
847
+
965
848
  test("migrates a checksum-valid plugin from the legacy singular path", async () => {
966
849
  const legacyPath = join(
967
850
  root,
@@ -991,21 +874,6 @@ describe("IntegrationService", () => {
991
874
  expect(await Bun.file(legacyPath).exists()).toBe(false)
992
875
  })
993
876
 
994
- test("preserves a user-owned Pi extension", async () => {
995
- const custom = "export default function customPiExtension() {}\n"
996
- await write(root, ".pi/extensions/agency-workbase.ts", custom)
997
-
998
- const result = await sync(root)
999
- expect(result.files[5]).toMatchObject({
1000
- name: "pi-extension",
1001
- state: "customized",
1002
- changed: false,
1003
- })
1004
- expect(
1005
- await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
1006
- ).toBe(custom)
1007
- })
1008
-
1009
877
  test("preserves user-owned TUI config and diagnostic plugin", async () => {
1010
878
  const customConfig = '{"theme":"custom"}\n'
1011
879
  const customPlugin =
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { createHash } from "node:crypto"
3
- import { join } from "node:path"
3
+ import { dirname, join } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
6
  import {
@@ -23,10 +23,19 @@ import {
23
23
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
24
24
  managedWorkbaseOpencodeTuiPlugin,
25
25
  } from "../workbase/opencode-tui-plugin-file"
26
- import {
27
- canUpdateManagedWorkbasePiExtension,
28
- managedWorkbasePiExtension,
29
- } from "../workbase/pi-extension-file"
26
+
27
+ const managedHeaderPattern =
28
+ /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
29
+
30
+ const canRemoveLegacyPiExtension = (content: string) => {
31
+ const match = content.match(managedHeaderPattern)
32
+ if (!match?.[1]) return false
33
+ return (
34
+ createHash("sha256")
35
+ .update(content.slice(match[0].length))
36
+ .digest("hex") === match[1]
37
+ )
38
+ }
30
39
 
31
40
  type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
32
41
 
@@ -37,7 +46,6 @@ interface IntegrationFileStatus {
37
46
  | "opencode-plugin"
38
47
  | "opencode-tui"
39
48
  | "opencode-tui-plugin"
40
- | "pi-extension"
41
49
  readonly path: string
42
50
  readonly state: IntegrationFileState
43
51
  readonly diagnostic: string
@@ -136,27 +144,6 @@ const describe = (
136
144
  "Run 'agency integration sync' to install /agency-debug.",
137
145
  }
138
146
  }
139
- if (name === "pi-extension") {
140
- return state === "managed"
141
- ? {
142
- diagnostic:
143
- "Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.",
144
- remediation: null,
145
- }
146
- : state === "customized"
147
- ? {
148
- diagnostic:
149
- "A user-owned Pi workbase extension is present and was preserved.",
150
- remediation: null,
151
- }
152
- : {
153
- diagnostic:
154
- "The managed Pi workbase extension needs synchronization.",
155
- remediation:
156
- "Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.",
157
- }
158
- }
159
-
160
147
  return state === "missing" || state === "drifted"
161
148
  ? {
162
149
  diagnostic: "Managed workbase instructions need synchronization.",
@@ -215,12 +202,6 @@ const inspect = (root: string) =>
215
202
  "agency-repository-skills.ts",
216
203
  )
217
204
  const tuiPluginPath = join(opencodeDirectory, "tui", "agency-debug.ts")
218
- const piExtensionPath = join(
219
- root,
220
- ".pi",
221
- "extensions",
222
- "agency-workbase.ts",
223
- )
224
205
  const files: IntegrationFileStatus[] = []
225
206
 
226
207
  files.push(
@@ -325,22 +306,6 @@ const inspect = (root: string) =>
325
306
  files.push(fileStatus("opencode-tui-plugin", tuiPluginPath, "missing"))
326
307
  }
327
308
 
328
- if ((yield* fs.readSymlinkTarget(piExtensionPath)) !== null) {
329
- files.push(fileStatus("pi-extension", piExtensionPath, "customized"))
330
- } else if (yield* fs.exists(piExtensionPath)) {
331
- files.push(
332
- classify(
333
- "pi-extension",
334
- piExtensionPath,
335
- yield* fs.readFile(piExtensionPath),
336
- managedWorkbasePiExtension,
337
- canUpdateManagedWorkbasePiExtension,
338
- ),
339
- )
340
- } else {
341
- files.push(fileStatus("pi-extension", piExtensionPath, "missing"))
342
- }
343
-
344
309
  return files
345
310
  })
346
311
 
@@ -418,6 +383,16 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
418
383
  yield* canRemoveLegacyOpencodeCommand(root)
419
384
  const removeLegacyOpencodePlugin =
420
385
  yield* canRemoveLegacyOpencodePlugin(root)
386
+ const legacyPiExtension = join(
387
+ root,
388
+ ".pi",
389
+ "extensions",
390
+ "agency-workbase.ts",
391
+ )
392
+ const removeLegacyPiExtension =
393
+ (yield* fs.readSymlinkTarget(legacyPiExtension)) === null &&
394
+ (yield* fs.exists(legacyPiExtension)) &&
395
+ canRemoveLegacyPiExtension(yield* fs.readFile(legacyPiExtension))
421
396
  const files: IntegrationSyncFile[] = []
422
397
 
423
398
  for (const status of statuses) {
@@ -442,9 +417,6 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
442
417
  status.path,
443
418
  managedWorkbaseOpencodeTuiPlugin,
444
419
  )
445
- } else {
446
- yield* fs.createDirectory(join(root, ".pi", "extensions"))
447
- yield* fs.writeFile(status.path, managedWorkbasePiExtension)
448
420
  }
449
421
  }
450
422
  files.push({
@@ -472,6 +444,13 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
472
444
  join(root, ".opencode", "command", "agency.md"),
473
445
  )
474
446
  }
447
+ if (removeLegacyPiExtension) {
448
+ yield* fs.deleteFile(legacyPiExtension)
449
+ yield* fs.deleteDirectoryIfEmpty(dirname(legacyPiExtension))
450
+ yield* fs.deleteDirectoryIfEmpty(
451
+ dirname(dirname(legacyPiExtension)),
452
+ )
453
+ }
475
454
 
476
455
  return { root, files }
477
456
  }),
@@ -575,6 +575,8 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
575
575
  })
576
576
 
577
577
  test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
578
+ await Bun.write(join(root, "bin", "gh"), "#!/bin/sh\nprintf '[]\\n'\n")
579
+ await chmod(join(root, "bin", "gh"), 0o755)
578
580
  for (const [id, branch] of [
579
581
  ["missing", "feat/missing"],
580
582
  ["conflict", "feat/conflict"],
@@ -649,6 +651,158 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
649
651
  ).toBe(false)
650
652
  })
651
653
 
654
+ test("reconciles a recorded merged PR without materializing an absent checkout", async () => {
655
+ await runTestEffect(
656
+ TaskService.pipe(
657
+ Effect.flatMap((service) =>
658
+ service.create(
659
+ {
660
+ id: "recorded-merged",
661
+ ticketUrl: null,
662
+ repo: "agency",
663
+ branch: "feat/example",
664
+ base: "main",
665
+ },
666
+ root,
667
+ ),
668
+ ),
669
+ ),
670
+ )
671
+ await git(
672
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
673
+ join(root, "repos/agency"),
674
+ )
675
+ await runTestEffect(
676
+ PullRequestService.pipe(
677
+ Effect.flatMap((service) =>
678
+ service.setUrl(
679
+ "recorded-merged",
680
+ undefined,
681
+ "https://github.com/example/agency/pull/42",
682
+ root,
683
+ ),
684
+ ),
685
+ ),
686
+ )
687
+
688
+ const applied = await runTestEffect(
689
+ SyncService.pipe(
690
+ Effect.flatMap((service) =>
691
+ service.reconcile({
692
+ cwd: root,
693
+ apply: true,
694
+ taskId: "recorded-merged",
695
+ }),
696
+ ),
697
+ ),
698
+ )
699
+ expect(applied.changes.map((change) => change.kind)).toEqual([
700
+ "record-pr",
701
+ "mark-done",
702
+ ])
703
+ expect(applied.executions[0]?.checkouts).toEqual([])
704
+ expect(
705
+ await Bun.file(join(root, "tasks/recorded-merged/code/agency")).exists(),
706
+ ).toBe(false)
707
+ })
708
+
709
+ test("reconciles a uniquely discovered merged PR without materializing", async () => {
710
+ await runTestEffect(
711
+ TaskService.pipe(
712
+ Effect.flatMap((service) =>
713
+ service.create(
714
+ {
715
+ id: "discovered-merged",
716
+ ticketUrl: null,
717
+ repo: "agency",
718
+ branch: "feat/example",
719
+ base: "main",
720
+ },
721
+ root,
722
+ ),
723
+ ),
724
+ ),
725
+ )
726
+ await git(
727
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
728
+ join(root, "repos/agency"),
729
+ )
730
+
731
+ const applied = await runTestEffect(
732
+ SyncService.pipe(
733
+ Effect.flatMap((service) =>
734
+ service.reconcile({
735
+ cwd: root,
736
+ apply: true,
737
+ taskId: "discovered-merged",
738
+ }),
739
+ ),
740
+ ),
741
+ )
742
+ expect(applied.changes.map((change) => change.kind)).toEqual([
743
+ "record-pr",
744
+ "mark-done",
745
+ ])
746
+ expect(applied.executions[0]?.checkouts).toEqual([])
747
+ expect(
748
+ await Bun.file(
749
+ join(root, "tasks/discovered-merged/code/agency"),
750
+ ).exists(),
751
+ ).toBe(false)
752
+ })
753
+
754
+ test("materializes when discovered PR evidence is ambiguous", async () => {
755
+ await runTestEffect(
756
+ TaskService.pipe(
757
+ Effect.flatMap((service) =>
758
+ service.create(
759
+ {
760
+ id: "ambiguous",
761
+ ticketUrl: null,
762
+ repo: "agency",
763
+ branch: "feat/example",
764
+ base: "main",
765
+ },
766
+ root,
767
+ ),
768
+ ),
769
+ ),
770
+ )
771
+ const gh = await Bun.file(join(root, "bin", "gh")).text()
772
+ await Bun.write(
773
+ join(root, "bin", "gh"),
774
+ gh
775
+ .replace('[{"number":42', '[{"number":42')
776
+ .replace(
777
+ "]\nJSON\n",
778
+ `,{\"number\":43,\"state\":\"MERGED\",\"title\":\"Ship again\",\"isDraft\":false,\"headRefName\":\"feat/example\",\"baseRefName\":\"main\",\"headRepository\":{\"nameWithOwner\":\"example/agency\"},\"url\":\"https://github.com/example/agency/pull/43\",\"mergedAt\":\"2100-01-01T00:00:00Z\",\"mergeCommit\":{\"oid\":\"def\"},\"mergeable\":\"MERGEABLE\"}]\nJSON\n`,
779
+ ),
780
+ )
781
+
782
+ const applied = await runTestEffect(
783
+ SyncService.pipe(
784
+ Effect.flatMap((service) =>
785
+ service.reconcile({
786
+ cwd: root,
787
+ apply: true,
788
+ taskId: "ambiguous",
789
+ }),
790
+ ),
791
+ ),
792
+ )
793
+ expect(applied.unresolved).toContainEqual(
794
+ expect.objectContaining({ kind: "multiple-prs" }),
795
+ )
796
+ expect(applied.changes).toContainEqual(
797
+ expect.objectContaining({ kind: "materialize-workspace" }),
798
+ )
799
+ expect(
800
+ await Bun.file(
801
+ join(root, "tasks/ambiguous/code/agency/README.md"),
802
+ ).text(),
803
+ ).toBe("example\n")
804
+ })
805
+
652
806
  test("leaves a missing checkout registration unresolved", async () => {
653
807
  await runTestEffect(
654
808
  TaskService.pipe(
@@ -162,6 +162,46 @@ const commandErrorSummary = (stderr: string, fallback: string) =>
162
162
  .map((line) => line.trim())
163
163
  .find(Boolean) ?? fallback
164
164
 
165
+ interface PullRequestQuery {
166
+ readonly remoteUrl: string | null
167
+ readonly remoteRepository: string
168
+ readonly result:
169
+ | {
170
+ readonly exitCode: number
171
+ readonly stdout: string
172
+ readonly stderr: string
173
+ }
174
+ | undefined
175
+ }
176
+
177
+ const mergedPullRequestFromGitHub = (
178
+ data: ExecutionData,
179
+ query: PullRequestQuery | undefined,
180
+ ) => {
181
+ if (!query?.result || query.result.exitCode !== 0) return null
182
+ const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
183
+ const details = existing
184
+ ? [parseJson<Record<string, unknown>>(query.result.stdout, {})]
185
+ : parseJson<Record<string, unknown>[]>(query.result.stdout, []).filter(
186
+ (item) =>
187
+ item.headRefName === data.branch && item.baseRefName === data.base,
188
+ )
189
+ if (details.length !== 1) return null
190
+ const current = recordFromGitHubJson(details[0]!)
191
+ if (
192
+ current.merged !== true ||
193
+ current.headRepository?.toLowerCase() !==
194
+ query.remoteRepository.toLowerCase() ||
195
+ current.headBranch !== data.branch ||
196
+ current.baseRepository?.toLowerCase() !==
197
+ current.repository.toLowerCase() ||
198
+ current.baseBranch !== data.base
199
+ ) {
200
+ return null
201
+ }
202
+ return current
203
+ }
204
+
165
205
  export class SyncService extends Effect.Service<SyncService>()("SyncService", {
166
206
  sync: () => ({
167
207
  reconcile: (
@@ -463,6 +503,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
463
503
  let revision = record.revision
464
504
  const codePath = join(dirname(record.path), "code")
465
505
  const checkoutStates: CheckoutState[] = []
506
+ const query = prQueries.get(record.key)
507
+ const remoteMergedPr = config.delivery
508
+ ? null
509
+ : mergedPullRequestFromGitHub(data, query)
510
+ const skipCheckoutReconciliation =
511
+ remoteMergedPr !== null &&
512
+ data.claim?.state !== "active" &&
513
+ !data.completion
466
514
  let materialize = false
467
515
  let workspaceConflict = false
468
516
  const declared: readonly (
@@ -473,7 +521,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
473
521
  ...(data.repos ?? []),
474
522
  ]
475
523
 
476
- for (const checkout of declared) {
524
+ for (const checkout of skipCheckoutReconciliation ? [] : declared) {
477
525
  const repositoryPath = join(root, "repos", checkout.repo)
478
526
  const checkoutPath = join(codePath, checkout.repo)
479
527
  const kind = "branch" in checkout ? "writable" : "reference"
@@ -811,7 +859,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
811
859
  state: "none",
812
860
  }
813
861
  let prConflict = false
814
- const query = prQueries.get(record.key)!
862
+ if (!query) {
863
+ return yield* new SyncError({
864
+ message: `Missing pull request query for '${record.key}'`,
865
+ })
866
+ }
815
867
  const { remoteUrl, remoteRepository } = query
816
868
 
817
869
  if (config.delivery && !remoteUrl) {
@@ -1,158 +0,0 @@
1
- import { createHash } from "node:crypto"
2
-
3
- const managedHeaderPattern =
4
- /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
5
-
6
- const checksum = (content: string) =>
7
- createHash("sha256").update(content).digest("hex")
8
-
9
- const body = `import { existsSync, readFileSync } from "node:fs"
10
- import { dirname, join, resolve } from "node:path"
11
- import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
12
-
13
- type AgencyContext = {
14
- root?: string
15
- checkout?: string
16
- target?: string
17
- }
18
-
19
- const contextTarget = (result: Record<string, any>): string | undefined => {
20
- const target = result.target
21
- if (target?.kind === "epic") return \`epic:\${target.epicId}\`
22
- if (target?.kind === "phase") {
23
- return \`execution-unit:phase/\${target.taskId}/\${target.phaseId}\`
24
- }
25
- if (target?.kind === "task") {
26
- return result.authority?.mode === "execution"
27
- ? \`execution-unit:task/\${target.taskId}\`
28
- : \`task:\${target.taskId}\`
29
- }
30
- }
31
-
32
- const discoverWorkbase = (directory: string) => {
33
- let current = directory
34
- while (true) {
35
- if (existsSync(join(current, "agency.json"))) return current
36
- const parent = dirname(current)
37
- if (parent === current) return
38
- current = parent
39
- }
40
- }
41
-
42
- const discoverCheckout = (directory: string, root: string | undefined) => {
43
- if (!root) return
44
- let current = directory
45
- while (current.startsWith(root)) {
46
- for (const name of ["PHASE.md", "TASK.md"]) {
47
- const document = join(current, name)
48
- if (!existsSync(document)) continue
49
- const repo = readFileSync(document, "utf8").match(/^repo:\\s*([^\\s]+)\\s*$/m)?.[1]
50
- if (!repo) return
51
- const checkout = join(current, "code", repo.replace(/^['\"]|['\"]$/g, ""))
52
- return existsSync(checkout) ? checkout : undefined
53
- }
54
- if (current === root) return
55
- current = dirname(current)
56
- }
57
- }
58
-
59
- const agencyContext = async (
60
- pi: ExtensionAPI,
61
- directory: string,
62
- ): Promise<AgencyContext | undefined> => {
63
- const task = process.env.AGENCY_TASK_ID
64
- const phase = process.env.AGENCY_PHASE_ID
65
- const args = task
66
- ? ["context", "--task", task, ...(phase ? ["--phase", phase] : []), "--compact", "--json"]
67
- : ["context", ".", "--compact", "--json"]
68
- const command = await pi.exec("agency", args, { timeout: 5000 })
69
- if (command.code !== 0) return
70
- const envelope = JSON.parse(command.stdout)
71
- if (envelope.ok !== true) return
72
- const result = envelope.result ?? {}
73
- const target = contextTarget(result)
74
- const document = result.target?.path
75
- const status = result.documents?.phase?.data?.status ?? result.documents?.task?.data?.status
76
- if (
77
- result.validation?.valid !== true ||
78
- !target ||
79
- !document ||
80
- dirname(document) !== resolve(directory) ||
81
- (target.startsWith("execution-unit:") &&
82
- (!result.authority?.writable?.checkoutPath || status !== "working"))
83
- ) return
84
- return {
85
- root: result.workbase?.root,
86
- checkout: result.authority?.writable?.checkoutPath,
87
- target,
88
- }
89
- }
90
-
91
- const extension = (pi: ExtensionAPI) => {
92
- const contexts = new Map<string, Promise<AgencyContext | undefined>>()
93
- const runtimeContext = (directory: string) => {
94
- let context = contexts.get(directory)
95
- if (!context) {
96
- context = agencyContext(pi, directory).catch(() => undefined)
97
- contexts.set(directory, context)
98
- }
99
- return context
100
- }
101
-
102
- pi.on("resources_discover", async (event) => {
103
- const context = await runtimeContext(event.cwd)
104
- const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(event.cwd)
105
- const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(event.cwd, root)
106
- if (!checkout) return
107
-
108
- const skillPaths = [
109
- join(checkout, ".claude", "skills"),
110
- join(checkout, ".agents", "skills"),
111
- join(checkout, ".opencode", "skill"),
112
- join(checkout, ".opencode", "skills"),
113
- join(checkout, CONFIG_DIR_NAME, "skills"),
114
- ].filter(existsSync)
115
- if (skillPaths.length === 0) return
116
- return { skillPaths: [...new Set(skillPaths)] }
117
- })
118
-
119
- pi.on("before_agent_start", async (event, ctx) => {
120
- const context = await runtimeContext(ctx.cwd)
121
- const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(ctx.cwd)
122
- if (!root) return
123
- const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(ctx.cwd, root)
124
- const instructionsPath = join(root, ".agency", "AGENTS.md")
125
- const instructions = existsSync(instructionsPath)
126
- ? readFileSync(instructionsPath, "utf8").trim()
127
- : undefined
128
- const activeTarget = process.env.AGENCY_TARGET
129
- const worker = activeTarget && context?.target === activeTarget
130
- ? \`Agency verified this Pi session as the active worker for \${activeTarget}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`
131
- : undefined
132
- const access = \`The complete Agency workbase is available at \${root}. Use absolute paths under that root when workbase context is needed. Agency context remains the authority for writes.\`
133
- const implementation = checkout
134
- ? \`Pi remains rooted in the task or phase directory for Agency instructions and context. Treat \${checkout} as the default implementation directory for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
135
- : undefined
136
-
137
- return {
138
- systemPrompt: [event.systemPrompt, instructions, access, worker, implementation]
139
- .filter(Boolean)
140
- .join("\\n\\n"),
141
- }
142
- })
143
- }
144
-
145
- export default extension
146
- `
147
-
148
- const renderManagedWorkbasePiExtension = (content: string) =>
149
- `// agency-managed: sha256=${checksum(content)}\n\n${content}`
150
-
151
- export const managedWorkbasePiExtension = renderManagedWorkbasePiExtension(body)
152
-
153
- export const canUpdateManagedWorkbasePiExtension = (content: string) => {
154
- const match = content.match(managedHeaderPattern)
155
- if (!match?.[1]) return false
156
-
157
- return checksum(content.slice(match[0].length)) === match[1]
158
- }