@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 +14 -16
- package/package.json +5 -1
- package/pi-extensions/agency.test.ts +117 -0
- package/pi-extensions/agency.ts +141 -0
- package/scripts/install-pi-extension.ts +25 -0
- package/src/cli.test.ts +0 -2
- package/src/commands/init.test.ts +3 -6
- package/src/commands/integration.test.ts +2 -20
- package/src/commands/integration.ts +3 -5
- package/src/services/IntegrationService.test.ts +24 -156
- package/src/services/IntegrationService.ts +31 -52
- package/src/services/SyncService.test.ts +154 -0
- package/src/services/SyncService.ts +54 -2
- package/src/workbase/pi-extension-file.ts +0 -158
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,
|
|
90
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
workbase, and exposes skills from
|
|
137
|
-
`.
|
|
138
|
-
|
|
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
|
|
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
|
|
441
|
-
|
|
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.
|
|
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
|
-
|
|
93
|
-
join(root, ".pi/extensions/agency-workbase.ts"),
|
|
94
|
-
).
|
|
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
|
-
|
|
96
|
-
skills, and the /agency-debug TUI diagnostic reports OpenCode
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
}
|