@markjaquith/agency 2.48.0 → 2.49.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 +22 -19
- package/cli.ts +9 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +58 -0
- package/src/cli-parser.ts +67 -6
- package/src/cli.test.ts +82 -3
- package/src/commands/claim.ts +26 -2
- package/src/commands/init.test.ts +3 -5
- package/src/commands/integration.test.ts +1 -16
- package/src/commands/integration.ts +0 -2
- package/src/commands/phase.ts +23 -2
- package/src/commands/task.ts +22 -2
- package/src/commands/work.test.ts +38 -4
- package/src/commands/work.ts +1 -1
- package/src/services/ClaimService.test.ts +59 -0
- package/src/services/ClaimService.ts +34 -1
- package/src/services/GraphMutationService.ts +16 -0
- package/src/services/IntegrationService.test.ts +32 -75
- package/src/services/IntegrationService.ts +27 -52
- package/src/services/PhaseService.ts +50 -3
- package/src/services/PullRequestService.test.ts +20 -0
- package/src/services/PullRequestService.ts +24 -3
- package/src/services/SyncService.test.ts +66 -0
- package/src/services/SyncService.ts +13 -0
- package/src/services/TaskPhaseService.test.ts +182 -0
- package/src/services/TaskService.ts +47 -3
- package/src/services/WorkbaseService.test.ts +58 -0
- package/src/services/WorkbaseService.ts +20 -0
- package/src/workbase/AGENTS.md +13 -8
- package/src/workbase/completion.ts +28 -0
- package/src/workbase/schemas.ts +9 -0
- package/src/workbase/AGENCY_COMMAND.md +0 -48
- package/src/workbase/opencode-command-file.ts +0 -30
package/src/commands/task.ts
CHANGED
|
@@ -38,6 +38,9 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
38
38
|
readonly pr?: boolean
|
|
39
39
|
readonly work?: boolean
|
|
40
40
|
readonly auto?: boolean
|
|
41
|
+
readonly noPullRequest?: boolean
|
|
42
|
+
readonly summary?: string
|
|
43
|
+
readonly evidenceUrl?: string
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export interface TaskInteraction {
|
|
@@ -304,7 +307,19 @@ export const task = (
|
|
|
304
307
|
new Error("Usage: agency task status <id> <status>"),
|
|
305
308
|
)
|
|
306
309
|
}
|
|
307
|
-
const record = yield* tasks.setStatus(
|
|
310
|
+
const record = yield* tasks.setStatus(
|
|
311
|
+
id,
|
|
312
|
+
status,
|
|
313
|
+
cwd,
|
|
314
|
+
options.noPullRequest
|
|
315
|
+
? {
|
|
316
|
+
summary: options.summary ?? "",
|
|
317
|
+
...(options.evidenceUrl
|
|
318
|
+
? { evidenceUrl: options.evidenceUrl }
|
|
319
|
+
: {}),
|
|
320
|
+
}
|
|
321
|
+
: undefined,
|
|
322
|
+
)
|
|
308
323
|
const { content: _, ...output } = record
|
|
309
324
|
log(
|
|
310
325
|
options.json
|
|
@@ -423,7 +438,7 @@ Subcommands:
|
|
|
423
438
|
create <id> Create a task without prompting
|
|
424
439
|
list List tasks
|
|
425
440
|
show <id> Show a task
|
|
426
|
-
status <id> <status> Set open, working, or
|
|
441
|
+
status <id> <status> Set open, working, dropped, or explicit non-PR done
|
|
427
442
|
update <id> Update task metadata
|
|
428
443
|
rename <id> <new-id> Rename a task and update graph references
|
|
429
444
|
move <id> Move a task with --epic or --no-epic
|
|
@@ -433,6 +448,11 @@ Subcommands:
|
|
|
433
448
|
Mutation option:
|
|
434
449
|
--if-revision <hash> Require the target's current revision
|
|
435
450
|
|
|
451
|
+
Non-PR completion options (status done only):
|
|
452
|
+
--no-pull-request Complete without a pull request
|
|
453
|
+
--summary <text> Required durable outcome summary
|
|
454
|
+
--evidence-url <url> Optional supporting record
|
|
455
|
+
|
|
436
456
|
Create options:
|
|
437
457
|
--ticket-url <url> External ticket URL (optional)
|
|
438
458
|
--description <text> Short description of the task
|
|
@@ -45,6 +45,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const taskDirectory = "/workbase/tasks/example"
|
|
48
|
+
const phaseDirectory = `${taskDirectory}/phases/implementation`
|
|
49
|
+
|
|
48
50
|
interface HarnessOptions {
|
|
49
51
|
readonly workspace?: ExecutionWorkspace
|
|
50
52
|
readonly materializeError?: Error
|
|
@@ -563,7 +565,7 @@ describe("work command", () => {
|
|
|
563
565
|
"--prompt",
|
|
564
566
|
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
565
567
|
],
|
|
566
|
-
cwd:
|
|
568
|
+
cwd: phaseDirectory,
|
|
567
569
|
})
|
|
568
570
|
expect(harness.statusUpdates).toEqual([
|
|
569
571
|
"phase:example:implementation:working",
|
|
@@ -593,7 +595,11 @@ describe("work command", () => {
|
|
|
593
595
|
data: {},
|
|
594
596
|
}
|
|
595
597
|
const harness = createHarness({
|
|
596
|
-
workspace:
|
|
598
|
+
workspace: {
|
|
599
|
+
...multiPhaseWorkspace,
|
|
600
|
+
taskPath: "/workbase/tasks/delivery/TASK.md",
|
|
601
|
+
phasePath: "/workbase/tasks/delivery/phases/build/PHASE.md",
|
|
602
|
+
},
|
|
597
603
|
taskRecords: [
|
|
598
604
|
{
|
|
599
605
|
id: "delivery",
|
|
@@ -615,7 +621,9 @@ describe("work command", () => {
|
|
|
615
621
|
"probe:opencode",
|
|
616
622
|
"launch:opencode",
|
|
617
623
|
])
|
|
618
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
624
|
+
expect(harness.launches[0]?.cwd).toBe(
|
|
625
|
+
"/workbase/tasks/delivery/phases/build",
|
|
626
|
+
)
|
|
619
627
|
})
|
|
620
628
|
|
|
621
629
|
test("requires an explicit target when input is disabled", async () => {
|
|
@@ -767,6 +775,18 @@ describe("work command", () => {
|
|
|
767
775
|
])
|
|
768
776
|
})
|
|
769
777
|
|
|
778
|
+
test("launches OpenCode in the phase directory with explicit context", async () => {
|
|
779
|
+
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
780
|
+
|
|
781
|
+
await harness.run({
|
|
782
|
+
taskId: "example",
|
|
783
|
+
phaseId: "implementation",
|
|
784
|
+
opencode: true,
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
expect(harness.launches[0]?.cwd).toBe(phaseDirectory)
|
|
788
|
+
})
|
|
789
|
+
|
|
770
790
|
test("sends the generated prompt only with --auto", async () => {
|
|
771
791
|
const harness = createHarness()
|
|
772
792
|
|
|
@@ -820,7 +840,7 @@ describe("work command", () => {
|
|
|
820
840
|
"--prompt",
|
|
821
841
|
"Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
822
842
|
],
|
|
823
|
-
cwd:
|
|
843
|
+
cwd: phaseDirectory,
|
|
824
844
|
})
|
|
825
845
|
})
|
|
826
846
|
|
|
@@ -897,6 +917,20 @@ describe("work command", () => {
|
|
|
897
917
|
expect(printed.environment.API_TOKEN).toBeUndefined()
|
|
898
918
|
})
|
|
899
919
|
|
|
920
|
+
test("prints the phase directory in the command contract", async () => {
|
|
921
|
+
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
922
|
+
const output = await captureLogs(() =>
|
|
923
|
+
harness.run({
|
|
924
|
+
taskId: "example",
|
|
925
|
+
phaseId: "implementation",
|
|
926
|
+
opencode: true,
|
|
927
|
+
printCommand: true,
|
|
928
|
+
}),
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
expect(JSON.parse(output.join("\n")).cwd).toBe(phaseDirectory)
|
|
932
|
+
})
|
|
933
|
+
|
|
900
934
|
test("does not reopen a forced terminal target in print-only mode", async () => {
|
|
901
935
|
const harness = createHarness({ taskStatuses: { example: "done" } })
|
|
902
936
|
|
package/src/commands/work.ts
CHANGED
|
@@ -261,7 +261,7 @@ export const work = (
|
|
|
261
261
|
prompt = workspace.phasePath
|
|
262
262
|
? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
263
263
|
: `${action} the task. Read ${workspace.taskPath}.`
|
|
264
|
-
launchPath = dirname(workspace.taskPath)
|
|
264
|
+
launchPath = dirname(workspace.phasePath ?? workspace.taskPath)
|
|
265
265
|
writablePath = workspace.writablePath
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -147,6 +147,65 @@ describe("claim service", () => {
|
|
|
147
147
|
})
|
|
148
148
|
})
|
|
149
149
|
|
|
150
|
+
test("finishes claimed non-PR work with durable completion evidence", async () => {
|
|
151
|
+
const initial = await inspect()
|
|
152
|
+
const acquired = await claim(initial.revision)
|
|
153
|
+
const finished = await runTestEffect(
|
|
154
|
+
ClaimService.pipe(
|
|
155
|
+
Effect.flatMap((service) =>
|
|
156
|
+
service.finish(
|
|
157
|
+
{
|
|
158
|
+
taskId: "single",
|
|
159
|
+
sessionId: "session-1",
|
|
160
|
+
revision: acquired.revision,
|
|
161
|
+
outcome: "done",
|
|
162
|
+
nonPrCompletion: {
|
|
163
|
+
summary: "Review completed; no code change was needed.",
|
|
164
|
+
evidenceUrl: "https://example.com/review",
|
|
165
|
+
},
|
|
166
|
+
now: at("2026-07-17T12:45:00.000Z"),
|
|
167
|
+
},
|
|
168
|
+
root,
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(finished.data).toMatchObject({
|
|
175
|
+
status: "done",
|
|
176
|
+
claim: { state: "finished", outcome: "done" },
|
|
177
|
+
completion: {
|
|
178
|
+
mode: "non-pr",
|
|
179
|
+
completedAt: "2026-07-17T12:45:00.000Z",
|
|
180
|
+
summary: "Review completed; no code change was needed.",
|
|
181
|
+
evidenceUrl: "https://example.com/review",
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("rejects invalid claimed non-PR completion", async () => {
|
|
187
|
+
const initial = await inspect()
|
|
188
|
+
const acquired = await claim(initial.revision)
|
|
189
|
+
await expect(
|
|
190
|
+
runTestEffect(
|
|
191
|
+
ClaimService.pipe(
|
|
192
|
+
Effect.flatMap((service) =>
|
|
193
|
+
service.finish(
|
|
194
|
+
{
|
|
195
|
+
taskId: "single",
|
|
196
|
+
sessionId: "session-1",
|
|
197
|
+
revision: acquired.revision,
|
|
198
|
+
outcome: "done",
|
|
199
|
+
nonPrCompletion: { summary: " " },
|
|
200
|
+
},
|
|
201
|
+
root,
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
).rejects.toThrow("summary must not be empty")
|
|
207
|
+
})
|
|
208
|
+
|
|
150
209
|
test("returns structured ownership and revision conflicts", async () => {
|
|
151
210
|
const initial = await inspect()
|
|
152
211
|
const acquired = await claim(initial.revision)
|
|
@@ -31,6 +31,10 @@ import {
|
|
|
31
31
|
type PhaseFrontmatter as PhaseData,
|
|
32
32
|
type TaskFrontmatter as TaskData,
|
|
33
33
|
} from "../workbase/schemas"
|
|
34
|
+
import {
|
|
35
|
+
buildNonPrCompletion,
|
|
36
|
+
type NonPrCompletionInput,
|
|
37
|
+
} from "../workbase/completion"
|
|
34
38
|
|
|
35
39
|
class ClaimError extends Data.TaggedError("ClaimError")<{
|
|
36
40
|
readonly message: string
|
|
@@ -83,6 +87,7 @@ interface OwnedClaimInput {
|
|
|
83
87
|
|
|
84
88
|
interface FinishInput extends OwnedClaimInput {
|
|
85
89
|
readonly outcome: "done" | "dropped"
|
|
90
|
+
readonly nonPrCompletion?: NonPrCompletionInput
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
interface ExpireClaimInput {
|
|
@@ -507,6 +512,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
507
512
|
finish: (input: FinishInput, startPath: string = process.cwd()) =>
|
|
508
513
|
Effect.gen(function* () {
|
|
509
514
|
assertIdentity("Session ID", input.sessionId)
|
|
515
|
+
if (input.nonPrCompletion && input.outcome !== "done") {
|
|
516
|
+
return yield* new ClaimError({
|
|
517
|
+
message: "Non-PR completion is valid only with a done outcome",
|
|
518
|
+
})
|
|
519
|
+
}
|
|
510
520
|
const service = yield* ClaimService
|
|
511
521
|
const inspected = yield* service.inspect(
|
|
512
522
|
input.taskId,
|
|
@@ -531,6 +541,22 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
531
541
|
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
532
542
|
})
|
|
533
543
|
}
|
|
544
|
+
if (input.nonPrCompletion && data.pr !== null) {
|
|
545
|
+
throw new ClaimError({
|
|
546
|
+
target: inspected.target.label,
|
|
547
|
+
message:
|
|
548
|
+
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
const completionResult = input.nonPrCompletion
|
|
552
|
+
? buildNonPrCompletion(input.nonPrCompletion, now)
|
|
553
|
+
: undefined
|
|
554
|
+
if (completionResult && "error" in completionResult) {
|
|
555
|
+
throw new ClaimError({
|
|
556
|
+
target: inspected.target.label,
|
|
557
|
+
message: completionResult.error,
|
|
558
|
+
})
|
|
559
|
+
}
|
|
534
560
|
const claim: ClaimRecord = {
|
|
535
561
|
...data.claim,
|
|
536
562
|
state: "finished",
|
|
@@ -540,8 +566,15 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
540
566
|
return {
|
|
541
567
|
data: {
|
|
542
568
|
...data,
|
|
543
|
-
status:
|
|
569
|
+
status: completionResult
|
|
570
|
+
? "done"
|
|
571
|
+
: input.outcome === "done"
|
|
572
|
+
? "working"
|
|
573
|
+
: "dropped",
|
|
544
574
|
claim,
|
|
575
|
+
...(completionResult
|
|
576
|
+
? { completion: completionResult.value }
|
|
577
|
+
: {}),
|
|
545
578
|
},
|
|
546
579
|
claim,
|
|
547
580
|
}
|
|
@@ -374,6 +374,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
374
374
|
message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
375
375
|
})
|
|
376
376
|
}
|
|
377
|
+
if (
|
|
378
|
+
updates.pr &&
|
|
379
|
+
"completion" in record.data &&
|
|
380
|
+
record.data.completion
|
|
381
|
+
) {
|
|
382
|
+
return yield* new GraphMutationError({
|
|
383
|
+
message:
|
|
384
|
+
"Reopen non-PR completed work before recording a pull request",
|
|
385
|
+
})
|
|
386
|
+
}
|
|
377
387
|
if (
|
|
378
388
|
executionChange &&
|
|
379
389
|
(yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
@@ -504,6 +514,12 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
504
514
|
message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
505
515
|
})
|
|
506
516
|
}
|
|
517
|
+
if (updates.pr && record.data.completion) {
|
|
518
|
+
return yield* new GraphMutationError({
|
|
519
|
+
message:
|
|
520
|
+
"Reopen non-PR completed work before recording a pull request",
|
|
521
|
+
})
|
|
522
|
+
}
|
|
507
523
|
const data: PhaseData = yield* decode(
|
|
508
524
|
PhaseFrontmatter,
|
|
509
525
|
{
|
|
@@ -6,10 +6,6 @@ import { dirname, join } from "node:path"
|
|
|
6
6
|
import { pathToFileURL } from "node:url"
|
|
7
7
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
8
8
|
import { managedWorkbaseAgents } from "../workbase/agents-file"
|
|
9
|
-
import {
|
|
10
|
-
canUpdateManagedWorkbaseOpencodeCommand,
|
|
11
|
-
managedWorkbaseOpencodeCommand,
|
|
12
|
-
} from "../workbase/opencode-command-file"
|
|
13
9
|
import { managedWorkbaseOpencode } from "../workbase/opencode-file"
|
|
14
10
|
import {
|
|
15
11
|
canUpdateManagedWorkbaseOpencodePlugin,
|
|
@@ -36,6 +32,12 @@ const managed = (prefix: string, body: string, suffix = "") => {
|
|
|
36
32
|
const managedBody = (content: string) =>
|
|
37
33
|
content.slice(content.indexOf("\n\n") + 2)
|
|
38
34
|
|
|
35
|
+
const legacyCommand = (content: string) =>
|
|
36
|
+
content.replace(
|
|
37
|
+
/^---\n/,
|
|
38
|
+
`---\n# agency-managed: sha256=${createHash("sha256").update(content).digest("hex")}\n`,
|
|
39
|
+
)
|
|
40
|
+
|
|
39
41
|
const status = (root: string) =>
|
|
40
42
|
runTestEffect(
|
|
41
43
|
IntegrationService.pipe(Effect.flatMap((service) => service.status(root))),
|
|
@@ -63,17 +65,11 @@ describe("IntegrationService", () => {
|
|
|
63
65
|
"missing",
|
|
64
66
|
"missing",
|
|
65
67
|
"missing",
|
|
66
|
-
"missing",
|
|
67
68
|
])
|
|
68
69
|
expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
|
|
69
70
|
|
|
70
71
|
await write(root, ".agency/AGENTS.md", managedWorkbaseAgents)
|
|
71
72
|
await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
|
|
72
|
-
await write(
|
|
73
|
-
root,
|
|
74
|
-
".opencode/command/agency.md",
|
|
75
|
-
managedWorkbaseOpencodeCommand,
|
|
76
|
-
)
|
|
77
73
|
await write(
|
|
78
74
|
root,
|
|
79
75
|
".opencode/plugin/agency-repository-skills.ts",
|
|
@@ -91,7 +87,6 @@ describe("IntegrationService", () => {
|
|
|
91
87
|
"managed",
|
|
92
88
|
"managed",
|
|
93
89
|
"managed",
|
|
94
|
-
"managed",
|
|
95
90
|
])
|
|
96
91
|
})
|
|
97
92
|
|
|
@@ -109,7 +104,6 @@ describe("IntegrationService", () => {
|
|
|
109
104
|
"missing",
|
|
110
105
|
"missing",
|
|
111
106
|
"missing",
|
|
112
|
-
"missing",
|
|
113
107
|
])
|
|
114
108
|
})
|
|
115
109
|
|
|
@@ -257,36 +251,6 @@ describe("IntegrationService", () => {
|
|
|
257
251
|
).toBe(true)
|
|
258
252
|
})
|
|
259
253
|
|
|
260
|
-
test("generates a positional OpenCode command for Agency workflows", () => {
|
|
261
|
-
expect(managedWorkbaseOpencodeCommand).toContain(
|
|
262
|
-
"description: Operate Agency work",
|
|
263
|
-
)
|
|
264
|
-
expect(managedWorkbaseOpencodeCommand).toContain("Workflow: `$1`")
|
|
265
|
-
expect(managedWorkbaseOpencodeCommand).toContain("Optional target: `$2`")
|
|
266
|
-
expect(managedWorkbaseOpencodeCommand).toContain(
|
|
267
|
-
"Complete request: `$ARGUMENTS`",
|
|
268
|
-
)
|
|
269
|
-
for (const workflow of [
|
|
270
|
-
"start",
|
|
271
|
-
"status",
|
|
272
|
-
"next",
|
|
273
|
-
"validate",
|
|
274
|
-
"finish",
|
|
275
|
-
"help",
|
|
276
|
-
]) {
|
|
277
|
-
expect(managedWorkbaseOpencodeCommand).toContain(`- \`${workflow}\`:`)
|
|
278
|
-
}
|
|
279
|
-
expect(managedWorkbaseOpencodeCommand).toContain("Never run `agency work`")
|
|
280
|
-
expect(
|
|
281
|
-
canUpdateManagedWorkbaseOpencodeCommand(managedWorkbaseOpencodeCommand),
|
|
282
|
-
).toBe(true)
|
|
283
|
-
expect(
|
|
284
|
-
canUpdateManagedWorkbaseOpencodeCommand(
|
|
285
|
-
managedWorkbaseOpencodeCommand.replace("Workflow: `$1`", "Workflow"),
|
|
286
|
-
),
|
|
287
|
-
).toBe(false)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
254
|
test("generates context-first safety and execution closeout guidance", () => {
|
|
291
255
|
const body = managedBody(managedWorkbaseAgents)
|
|
292
256
|
|
|
@@ -297,7 +261,9 @@ describe("IntegrationService", () => {
|
|
|
297
261
|
expect(body).toContain("Only `done` satisfies a dependency")
|
|
298
262
|
expect(body).toContain("Require explicit user intent")
|
|
299
263
|
expect(body).toContain("changing repository")
|
|
300
|
-
expect(body).toMatch(
|
|
264
|
+
expect(body).toMatch(
|
|
265
|
+
/archiving, restoring,\s+dropping, reopening, or completing work without a pull request/,
|
|
266
|
+
)
|
|
301
267
|
expect(body).toContain("Never invent entity IDs")
|
|
302
268
|
expect(body).toContain("Preserve parent backlinks")
|
|
303
269
|
expect(body).toContain("dirty-worktree, active-claim, revision")
|
|
@@ -317,13 +283,14 @@ describe("IntegrationService", () => {
|
|
|
317
283
|
expect(body).toContain("pausing or handing off")
|
|
318
284
|
expect(body).toContain("`agency finish`")
|
|
319
285
|
expect(body).toContain("`agency sync --apply`")
|
|
286
|
+
expect(body).toContain("`--no-pull-request --summary <text>`")
|
|
320
287
|
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
321
288
|
expect(body).toContain("PR state, current head, diff summary")
|
|
322
289
|
expect(body).toContain("Run `agency validate` before reporting completion")
|
|
323
290
|
expect(body).toContain("agency integration status")
|
|
324
291
|
expect(body).not.toContain("SKILL.md")
|
|
325
292
|
expect(body).not.toContain("~/.agents")
|
|
326
|
-
expect(body).toContain(".opencode/command/agency.md")
|
|
293
|
+
expect(body).not.toContain(".opencode/command/agency.md")
|
|
327
294
|
})
|
|
328
295
|
|
|
329
296
|
test("configures Agency agents with complete workbase access", () => {
|
|
@@ -454,7 +421,6 @@ describe("IntegrationService", () => {
|
|
|
454
421
|
expect(first.files).toMatchObject([
|
|
455
422
|
{ name: "agents", state: "customized", changed: false },
|
|
456
423
|
{ name: "opencode", state: "managed", changed: true },
|
|
457
|
-
{ name: "opencode-command", state: "managed", changed: true },
|
|
458
424
|
{ name: "opencode-plugin", state: "managed", changed: true },
|
|
459
425
|
{ name: "opencode-tui", state: "managed", changed: true },
|
|
460
426
|
{ name: "opencode-tui-plugin", state: "managed", changed: true },
|
|
@@ -469,8 +435,8 @@ describe("IntegrationService", () => {
|
|
|
469
435
|
managedWorkbaseOpencode,
|
|
470
436
|
)
|
|
471
437
|
expect(
|
|
472
|
-
await Bun.file(join(root, ".opencode/command/agency.md")).
|
|
473
|
-
).toBe(
|
|
438
|
+
await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
|
|
439
|
+
).toBe(false)
|
|
474
440
|
expect(
|
|
475
441
|
await Bun.file(
|
|
476
442
|
join(root, ".opencode/plugin/agency-repository-skills.ts"),
|
|
@@ -524,7 +490,7 @@ describe("IntegrationService", () => {
|
|
|
524
490
|
await write(root, ".opencode/plugins/agency-repository-skills.ts", custom)
|
|
525
491
|
|
|
526
492
|
let result = await sync(root)
|
|
527
|
-
expect(result.files[
|
|
493
|
+
expect(result.files[2]).toMatchObject({
|
|
528
494
|
name: "opencode-plugin",
|
|
529
495
|
path: join(root, ".opencode/plugins/agency-repository-skills.ts"),
|
|
530
496
|
state: "customized",
|
|
@@ -539,7 +505,7 @@ describe("IntegrationService", () => {
|
|
|
539
505
|
await unlink(join(root, ".opencode/plugins/agency-repository-skills.ts"))
|
|
540
506
|
await write(root, ".opencode/plugin/agency-repository-skills.ts", custom)
|
|
541
507
|
result = await sync(root)
|
|
542
|
-
expect(result.files[
|
|
508
|
+
expect(result.files[2]).toMatchObject({
|
|
543
509
|
path: join(root, ".opencode/plugin/agency-repository-skills.ts"),
|
|
544
510
|
state: "customized",
|
|
545
511
|
changed: false,
|
|
@@ -554,14 +520,14 @@ describe("IntegrationService", () => {
|
|
|
554
520
|
await write(root, ".opencode/tui/agency-debug.ts", customPlugin)
|
|
555
521
|
|
|
556
522
|
const result = await sync(root)
|
|
557
|
-
expect(result.files[
|
|
523
|
+
expect(result.files[3]).toMatchObject({
|
|
558
524
|
name: "opencode-tui",
|
|
559
525
|
path: join(root, ".opencode/tui.json"),
|
|
560
526
|
state: "customized",
|
|
561
527
|
changed: false,
|
|
562
528
|
remediation: expect.stringContaining("plugin list"),
|
|
563
529
|
})
|
|
564
|
-
expect(result.files[
|
|
530
|
+
expect(result.files[4]).toMatchObject({
|
|
565
531
|
name: "opencode-tui-plugin",
|
|
566
532
|
state: "customized",
|
|
567
533
|
changed: false,
|
|
@@ -574,32 +540,23 @@ describe("IntegrationService", () => {
|
|
|
574
540
|
).toBe(customPlugin)
|
|
575
541
|
})
|
|
576
542
|
|
|
577
|
-
test("
|
|
578
|
-
const
|
|
579
|
-
|
|
543
|
+
test("removes a checksum-valid legacy command and preserves a customized file", async () => {
|
|
544
|
+
const path = ".opencode/command/agency.md"
|
|
545
|
+
const legacy = legacyCommand(
|
|
546
|
+
"---\ndescription: Operate Agency work\n---\n\nLegacy command.\n",
|
|
547
|
+
)
|
|
548
|
+
await write(root, path, legacy)
|
|
580
549
|
|
|
581
|
-
|
|
582
|
-
expect(
|
|
583
|
-
name: "opencode-command",
|
|
584
|
-
path: join(root, ".opencode/commands/agency.md"),
|
|
585
|
-
state: "customized",
|
|
586
|
-
changed: false,
|
|
587
|
-
})
|
|
588
|
-
expect(
|
|
589
|
-
await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
|
|
590
|
-
).toBe(false)
|
|
550
|
+
await status(root)
|
|
551
|
+
expect(await Bun.file(join(root, path)).text()).toBe(legacy)
|
|
591
552
|
|
|
592
|
-
await
|
|
593
|
-
await
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
})
|
|
600
|
-
expect(
|
|
601
|
-
await Bun.file(join(root, ".opencode/command/agency.md")).text(),
|
|
602
|
-
).toBe(custom)
|
|
553
|
+
await sync(root)
|
|
554
|
+
expect(await Bun.file(join(root, path)).exists()).toBe(false)
|
|
555
|
+
|
|
556
|
+
const custom = `${legacy}User edit.\n`
|
|
557
|
+
await write(root, path, custom)
|
|
558
|
+
await sync(root)
|
|
559
|
+
expect(await Bun.file(join(root, path)).text()).toBe(custom)
|
|
603
560
|
})
|
|
604
561
|
|
|
605
562
|
test("migrates checksum-valid root instructions", async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
2
3
|
import { join } from "node:path"
|
|
3
4
|
import { FileSystemService } from "./FileSystemService"
|
|
4
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
@@ -10,10 +11,6 @@ import {
|
|
|
10
11
|
canUpdateManagedWorkbaseOpencode,
|
|
11
12
|
managedWorkbaseOpencode,
|
|
12
13
|
} from "../workbase/opencode-file"
|
|
13
|
-
import {
|
|
14
|
-
canUpdateManagedWorkbaseOpencodeCommand,
|
|
15
|
-
managedWorkbaseOpencodeCommand,
|
|
16
|
-
} from "../workbase/opencode-command-file"
|
|
17
14
|
import {
|
|
18
15
|
canUpdateManagedWorkbaseOpencodePlugin,
|
|
19
16
|
managedWorkbaseOpencodePlugin,
|
|
@@ -33,7 +30,6 @@ interface IntegrationFileStatus {
|
|
|
33
30
|
readonly name:
|
|
34
31
|
| "agents"
|
|
35
32
|
| "opencode"
|
|
36
|
-
| "opencode-command"
|
|
37
33
|
| "opencode-plugin"
|
|
38
34
|
| "opencode-tui"
|
|
39
35
|
| "opencode-tui-plugin"
|
|
@@ -74,25 +70,6 @@ const describe = (
|
|
|
74
70
|
"Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.",
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
|
-
if (name === "opencode-command") {
|
|
78
|
-
return state === "managed"
|
|
79
|
-
? {
|
|
80
|
-
diagnostic: "Agency's managed OpenCode /agency command is current.",
|
|
81
|
-
remediation: null,
|
|
82
|
-
}
|
|
83
|
-
: state === "customized"
|
|
84
|
-
? {
|
|
85
|
-
diagnostic:
|
|
86
|
-
"A user-owned OpenCode /agency command is present and was preserved.",
|
|
87
|
-
remediation: null,
|
|
88
|
-
}
|
|
89
|
-
: {
|
|
90
|
-
diagnostic:
|
|
91
|
-
"The managed OpenCode /agency command needs synchronization.",
|
|
92
|
-
remediation:
|
|
93
|
-
"Run 'agency integration sync' to install the managed /agency command.",
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
73
|
if (name === "opencode-plugin") {
|
|
97
74
|
return state === "managed"
|
|
98
75
|
? {
|
|
@@ -202,8 +179,6 @@ const inspect = (root: string) =>
|
|
|
202
179
|
const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
|
|
203
180
|
const tuiPath = join(opencodeDirectory, "tui.jsonc")
|
|
204
181
|
const tuiJsonPath = join(opencodeDirectory, "tui.json")
|
|
205
|
-
const commandPath = join(opencodeDirectory, "command", "agency.md")
|
|
206
|
-
const pluralCommandPath = join(opencodeDirectory, "commands", "agency.md")
|
|
207
182
|
const pluginPath = join(
|
|
208
183
|
opencodeDirectory,
|
|
209
184
|
"plugin",
|
|
@@ -252,29 +227,6 @@ const inspect = (root: string) =>
|
|
|
252
227
|
files.push(fileStatus("opencode", opencodePath, "missing"))
|
|
253
228
|
}
|
|
254
229
|
|
|
255
|
-
if ((yield* fs.readSymlinkTarget(commandPath)) !== null) {
|
|
256
|
-
files.push(fileStatus("opencode-command", commandPath, "customized"))
|
|
257
|
-
} else if (
|
|
258
|
-
(yield* fs.readSymlinkTarget(pluralCommandPath)) !== null ||
|
|
259
|
-
(yield* fs.exists(pluralCommandPath))
|
|
260
|
-
) {
|
|
261
|
-
files.push(
|
|
262
|
-
fileStatus("opencode-command", pluralCommandPath, "customized"),
|
|
263
|
-
)
|
|
264
|
-
} else if (yield* fs.exists(commandPath)) {
|
|
265
|
-
files.push(
|
|
266
|
-
classify(
|
|
267
|
-
"opencode-command",
|
|
268
|
-
commandPath,
|
|
269
|
-
yield* fs.readFile(commandPath),
|
|
270
|
-
managedWorkbaseOpencodeCommand,
|
|
271
|
-
canUpdateManagedWorkbaseOpencodeCommand,
|
|
272
|
-
),
|
|
273
|
-
)
|
|
274
|
-
} else {
|
|
275
|
-
files.push(fileStatus("opencode-command", commandPath, "missing"))
|
|
276
|
-
}
|
|
277
|
-
|
|
278
230
|
if ((yield* fs.readSymlinkTarget(pluginPath)) !== null) {
|
|
279
231
|
files.push(fileStatus("opencode-plugin", pluginPath, "customized"))
|
|
280
232
|
} else if (
|
|
@@ -348,6 +300,25 @@ const canRemoveLegacyAgents = (root: string) =>
|
|
|
348
300
|
return canUpdateManagedWorkbaseAgents(yield* fs.readFile(path))
|
|
349
301
|
})
|
|
350
302
|
|
|
303
|
+
const canRemoveLegacyOpencodeCommand = (root: string) =>
|
|
304
|
+
Effect.gen(function* () {
|
|
305
|
+
const fs = yield* FileSystemService
|
|
306
|
+
const path = join(root, ".opencode", "command", "agency.md")
|
|
307
|
+
if (
|
|
308
|
+
(yield* fs.readSymlinkTarget(path)) !== null ||
|
|
309
|
+
!(yield* fs.exists(path))
|
|
310
|
+
)
|
|
311
|
+
return false
|
|
312
|
+
|
|
313
|
+
const content = yield* fs.readFile(path)
|
|
314
|
+
const header = /^---\r?\n# agency-managed: sha256=([a-f0-9]{64})\r?\n/
|
|
315
|
+
const match = content.match(header)
|
|
316
|
+
if (!match?.[1]) return false
|
|
317
|
+
|
|
318
|
+
const canonical = content.replace(header, "---\n")
|
|
319
|
+
return createHash("sha256").update(canonical).digest("hex") === match[1]
|
|
320
|
+
})
|
|
321
|
+
|
|
351
322
|
export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
352
323
|
"IntegrationService",
|
|
353
324
|
{
|
|
@@ -370,6 +341,8 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
370
341
|
(status) =>
|
|
371
342
|
status.name === "opencode" && status.state !== "customized",
|
|
372
343
|
) && (yield* canRemoveLegacyAgents(root))
|
|
344
|
+
const removeLegacyOpencodeCommand =
|
|
345
|
+
yield* canRemoveLegacyOpencodeCommand(root)
|
|
373
346
|
const files: IntegrationSyncFile[] = []
|
|
374
347
|
|
|
375
348
|
for (const status of statuses) {
|
|
@@ -382,9 +355,6 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
382
355
|
} else if (status.name === "opencode") {
|
|
383
356
|
yield* fs.createDirectory(join(root, ".opencode"))
|
|
384
357
|
yield* fs.writeFile(status.path, managedWorkbaseOpencode)
|
|
385
|
-
} else if (status.name === "opencode-command") {
|
|
386
|
-
yield* fs.createDirectory(join(root, ".opencode", "command"))
|
|
387
|
-
yield* fs.writeFile(status.path, managedWorkbaseOpencodeCommand)
|
|
388
358
|
} else if (status.name === "opencode-plugin") {
|
|
389
359
|
yield* fs.createDirectory(join(root, ".opencode", "plugin"))
|
|
390
360
|
yield* fs.writeFile(status.path, managedWorkbaseOpencodePlugin)
|
|
@@ -411,6 +381,11 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
411
381
|
}
|
|
412
382
|
|
|
413
383
|
if (removeLegacyAgents) yield* fs.deleteFile(join(root, "AGENTS.md"))
|
|
384
|
+
if (removeLegacyOpencodeCommand) {
|
|
385
|
+
yield* fs.deleteFile(
|
|
386
|
+
join(root, ".opencode", "command", "agency.md"),
|
|
387
|
+
)
|
|
388
|
+
}
|
|
414
389
|
|
|
415
390
|
return { root, files }
|
|
416
391
|
}),
|