@markjaquith/agency 3.1.0 → 3.2.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 +15 -16
- package/cli-main.ts +1 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +12 -4
- package/src/cli-parser.ts +12 -12
- package/src/cli.test.ts +10 -19
- package/src/commands/init.test.ts +1 -5
- package/src/commands/work.test.ts +43 -0
- package/src/commands/work.ts +5 -4
- package/src/commands/worktree.ts +2 -0
- package/src/services/IntegrationService.test.ts +10 -18
- package/src/services/WorktreeLock.test.ts +81 -2
- package/src/services/WorktreeLock.ts +42 -5
- package/src/services/WorktreeService.ts +6 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/opencode-file.ts +1 -13
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ workbase/
|
|
|
66
66
|
.agency/
|
|
67
67
|
AGENTS.md # managed Agency instructions
|
|
68
68
|
.opencode/
|
|
69
|
-
opencode.jsonc # managed
|
|
69
|
+
opencode.jsonc # managed planning agent, instructions, and reference
|
|
70
70
|
tui.jsonc # managed TUI plugin registration
|
|
71
71
|
plugins/agency-repository-skills.ts # managed workbase access and checkout skills
|
|
72
72
|
tui/agency-debug.ts # managed /agency-debug TUI diagnostic
|
|
@@ -116,17 +116,14 @@ Agency-managed root `AGENTS.md` to `.agency/AGENTS.md` once the OpenCode config
|
|
|
116
116
|
can load the hidden file. A customized root file, including a symlink, is
|
|
117
117
|
preserved as user-owned content.
|
|
118
118
|
|
|
119
|
-
The OpenCode config
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
operation.
|
|
128
|
-
When the subagent launches work in another agent, it verifies that the agent
|
|
129
|
-
started and returns without waiting for the task to finish.
|
|
119
|
+
The OpenCode config loads Agency's hidden instructions in addition to any
|
|
120
|
+
user-owned root `AGENTS.md`, advertises the complete workbase as one portable
|
|
121
|
+
reference, and replaces the built-in Plan agent with `agency-plan`. That
|
|
122
|
+
planning agent can update `TASK.md`, `PHASE.md`, and `EPIC.md`, inspect the
|
|
123
|
+
workbase through read-only Agency commands, and use explicit Agency CLI
|
|
124
|
+
permissions to create or update planning structure. Its normal research tools
|
|
125
|
+
and the complete Agency CLI remain available; managed Agency instructions and
|
|
126
|
+
reported authority govern each operation.
|
|
130
127
|
The TUI-only `/agency-debug` command reports TUI companion initialization and
|
|
131
128
|
whether the server plugin registered writable-checkout skills. It uses a native
|
|
132
129
|
toast and does not submit a prompt to an LLM. When no writable checkout skill
|
|
@@ -1031,7 +1028,7 @@ until restored.
|
|
|
1031
1028
|
|
|
1032
1029
|
```text
|
|
1033
1030
|
agency work [<directory> | --epic <epic-id>] [--agent <name>] [--auto] [--print-command]
|
|
1034
|
-
agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
|
|
1031
|
+
agency work prepare [target] [--evidence <json-or-path>] [--force] [--dry-run] [--json]
|
|
1035
1032
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
1036
1033
|
agency push [--json]
|
|
1037
1034
|
agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
|
|
@@ -1065,9 +1062,11 @@ work and reading context. It does not prescribe how callers present or execute
|
|
|
1065
1062
|
prepared work.
|
|
1066
1063
|
The evidence argument may be an evidence object, task-creation JSON, or a path to
|
|
1067
1064
|
either. Use `--dry-run` to report planned fetch, branch, and worktree changes
|
|
1068
|
-
without applying them.
|
|
1069
|
-
|
|
1070
|
-
|
|
1065
|
+
without applying them. Use `--force` to prepare work blocked by readiness, such
|
|
1066
|
+
as a follow-on phase whose dependency is still active; preparation still does
|
|
1067
|
+
not launch an agent or change lifecycle status. Validation reuse never skips
|
|
1068
|
+
readiness, active-claim, repository, ownership, reference-drift, dirty-workspace,
|
|
1069
|
+
or worktree safety checks.
|
|
1071
1070
|
|
|
1072
1071
|
The authoritative implementation locations for this contract are
|
|
1073
1072
|
`src/commands/task.ts` (creation output),
|
package/cli-main.ts
CHANGED
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -397,9 +397,11 @@ describe("strict CLI parsing", () => {
|
|
|
397
397
|
commandName: "work",
|
|
398
398
|
values: { force: true },
|
|
399
399
|
})
|
|
400
|
-
expect(
|
|
401
|
-
|
|
402
|
-
|
|
400
|
+
expect(parseCli(["work", "prepare", "example", "--force"])).toMatchObject({
|
|
401
|
+
commandName: "work",
|
|
402
|
+
args: ["prepare", "example"],
|
|
403
|
+
values: { force: true },
|
|
404
|
+
})
|
|
403
405
|
})
|
|
404
406
|
|
|
405
407
|
test("preserves every argument after pr without parsing it", () => {
|
|
@@ -664,11 +666,17 @@ describe("strict CLI parsing", () => {
|
|
|
664
666
|
"--phase",
|
|
665
667
|
"verify",
|
|
666
668
|
"--dry-run",
|
|
669
|
+
"--force",
|
|
667
670
|
]),
|
|
668
671
|
).toMatchObject({
|
|
669
672
|
commandName: "worktree",
|
|
670
673
|
args: ["rebuild", "example", "verify"],
|
|
671
|
-
values: {
|
|
674
|
+
values: {
|
|
675
|
+
task: "example",
|
|
676
|
+
phase: "verify",
|
|
677
|
+
"dry-run": true,
|
|
678
|
+
force: true,
|
|
679
|
+
},
|
|
672
680
|
})
|
|
673
681
|
expectUsageError(
|
|
674
682
|
["worktree", "inspect", "example", "--dry-run"],
|
package/src/cli-parser.ts
CHANGED
|
@@ -870,6 +870,7 @@ const commands = {
|
|
|
870
870
|
...outputOptions,
|
|
871
871
|
...entitySelectorOptions,
|
|
872
872
|
"dry-run": { type: "boolean" },
|
|
873
|
+
force: { type: "boolean" },
|
|
873
874
|
},
|
|
874
875
|
subcommands: {
|
|
875
876
|
list: {
|
|
@@ -886,37 +887,37 @@ const commands = {
|
|
|
886
887
|
},
|
|
887
888
|
prepare: {
|
|
888
889
|
usage:
|
|
889
|
-
"agency worktree prepare <task-id> [phase-id] [--dry-run] [--json]",
|
|
890
|
+
"agency worktree prepare <task-id> [phase-id] [--dry-run] [--force] [--json]",
|
|
890
891
|
minArgs: 1,
|
|
891
892
|
maxArgs: 2,
|
|
892
|
-
options: ["dry-run", "json", "task", "phase"],
|
|
893
|
+
options: ["dry-run", "force", "json", "task", "phase"],
|
|
893
894
|
},
|
|
894
895
|
remove: {
|
|
895
896
|
usage:
|
|
896
|
-
"agency worktree remove <task-id> [phase-id] [--dry-run] [--json]",
|
|
897
|
+
"agency worktree remove <task-id> [phase-id] [--dry-run] [--force] [--json]",
|
|
897
898
|
minArgs: 1,
|
|
898
899
|
maxArgs: 2,
|
|
899
|
-
options: ["dry-run", "json", "task", "phase"],
|
|
900
|
+
options: ["dry-run", "force", "json", "task", "phase"],
|
|
900
901
|
},
|
|
901
902
|
rebuild: {
|
|
902
903
|
usage:
|
|
903
|
-
"agency worktree rebuild <task-id> [phase-id] [--dry-run] [--json]",
|
|
904
|
+
"agency worktree rebuild <task-id> [phase-id] [--dry-run] [--force] [--json]",
|
|
904
905
|
minArgs: 1,
|
|
905
906
|
maxArgs: 2,
|
|
906
|
-
options: ["dry-run", "json", "task", "phase"],
|
|
907
|
+
options: ["dry-run", "force", "json", "task", "phase"],
|
|
907
908
|
},
|
|
908
909
|
repair: {
|
|
909
910
|
usage:
|
|
910
|
-
"agency worktree repair <task-id> [phase-id] [--dry-run] [--json]",
|
|
911
|
+
"agency worktree repair <task-id> [phase-id] [--dry-run] [--force] [--json]",
|
|
911
912
|
minArgs: 1,
|
|
912
913
|
maxArgs: 2,
|
|
913
|
-
options: ["dry-run", "json", "task", "phase"],
|
|
914
|
+
options: ["dry-run", "force", "json", "task", "phase"],
|
|
914
915
|
},
|
|
915
916
|
},
|
|
916
917
|
},
|
|
917
918
|
work: {
|
|
918
919
|
usage:
|
|
919
|
-
"agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
|
|
920
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]",
|
|
920
921
|
options: {
|
|
921
922
|
...commonOptions,
|
|
922
923
|
...entitySelectorOptions,
|
|
@@ -932,7 +933,7 @@ const commands = {
|
|
|
932
933
|
},
|
|
933
934
|
command: {
|
|
934
935
|
usage:
|
|
935
|
-
"agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
|
|
936
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]",
|
|
936
937
|
minArgs: 0,
|
|
937
938
|
maxArgs: 2,
|
|
938
939
|
options: [
|
|
@@ -1690,8 +1691,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1690
1691
|
parsed.values.claude ||
|
|
1691
1692
|
parsed.values.agent ||
|
|
1692
1693
|
parsed.values.auto ||
|
|
1693
|
-
parsed.values["print-command"]
|
|
1694
|
-
parsed.values.force))
|
|
1694
|
+
parsed.values["print-command"]))
|
|
1695
1695
|
) {
|
|
1696
1696
|
throw usageError(
|
|
1697
1697
|
preparing
|
package/src/cli.test.ts
CHANGED
|
@@ -1310,7 +1310,12 @@ status: open
|
|
|
1310
1310
|
...process.env,
|
|
1311
1311
|
...contract.environment,
|
|
1312
1312
|
XDG_CONFIG_HOME: isolatedConfigHome,
|
|
1313
|
+
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
|
1313
1314
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
|
1315
|
+
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
|
1316
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
1317
|
+
model: "opencode/big-pickle",
|
|
1318
|
+
}),
|
|
1314
1319
|
}
|
|
1315
1320
|
const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
|
|
1316
1321
|
cwd: contract.cwd,
|
|
@@ -1373,12 +1378,7 @@ status: open
|
|
|
1373
1378
|
}
|
|
1374
1379
|
if (launch === launches[0]) {
|
|
1375
1380
|
expect(effectiveConfig.instructions).toContain(".agency/AGENTS.md")
|
|
1376
|
-
expect(effectiveConfig.agent.agency).
|
|
1377
|
-
description: expect.stringContaining(
|
|
1378
|
-
"Agency workbase orchestration",
|
|
1379
|
-
),
|
|
1380
|
-
mode: "subagent",
|
|
1381
|
-
})
|
|
1381
|
+
expect(effectiveConfig.agent.agency).toBeUndefined()
|
|
1382
1382
|
expect(effectiveConfig.references).toEqual({
|
|
1383
1383
|
workbase: {
|
|
1384
1384
|
path: "..",
|
|
@@ -1386,18 +1386,6 @@ status: open
|
|
|
1386
1386
|
"Complete Agency workbase context; write authority still comes only from agency context",
|
|
1387
1387
|
},
|
|
1388
1388
|
})
|
|
1389
|
-
const agencyProbe = Bun.spawnSync(
|
|
1390
|
-
["opencode", "debug", "agent", "agency"],
|
|
1391
|
-
{ cwd: contract.cwd, env: environment },
|
|
1392
|
-
)
|
|
1393
|
-
expect(agencyProbe.exitCode).toBe(0)
|
|
1394
|
-
expect(JSON.parse(agencyProbe.stdout.toString())).toMatchObject({
|
|
1395
|
-
name: "agency",
|
|
1396
|
-
description: expect.stringContaining(
|
|
1397
|
-
"Agency workbase orchestration",
|
|
1398
|
-
),
|
|
1399
|
-
mode: "subagent",
|
|
1400
|
-
})
|
|
1401
1389
|
for (const document of documents) {
|
|
1402
1390
|
const read = Bun.spawnSync(
|
|
1403
1391
|
[
|
|
@@ -1426,7 +1414,9 @@ status: open
|
|
|
1426
1414
|
...process.env,
|
|
1427
1415
|
PATH: `${agencyBin}:${process.env.PATH ?? ""}`,
|
|
1428
1416
|
XDG_CONFIG_HOME: isolatedConfigHome,
|
|
1417
|
+
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
|
1429
1418
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
|
1419
|
+
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
|
1430
1420
|
}
|
|
1431
1421
|
delete directEnvironment.OPENCODE_CONFIG
|
|
1432
1422
|
delete directEnvironment.OPENCODE_CONFIG_CONTENT
|
|
@@ -1497,6 +1487,7 @@ status: open
|
|
|
1497
1487
|
env: {
|
|
1498
1488
|
...directEnvironment,
|
|
1499
1489
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
1490
|
+
model: "opencode/big-pickle",
|
|
1500
1491
|
permission: { external_directory: { "*": "deny" } },
|
|
1501
1492
|
}),
|
|
1502
1493
|
},
|
|
@@ -1595,7 +1586,7 @@ status: open
|
|
|
1595
1586
|
graph.nodes.find((node: any) => node.id === "task:pipeline").status,
|
|
1596
1587
|
).toBe("working")
|
|
1597
1588
|
},
|
|
1598
|
-
|
|
1589
|
+
360_000,
|
|
1599
1590
|
)
|
|
1600
1591
|
|
|
1601
1592
|
test("envelopes help and version output in machine mode", async () => {
|
|
@@ -45,11 +45,7 @@ describe("init command", () => {
|
|
|
45
45
|
).text()
|
|
46
46
|
const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
|
|
47
47
|
expect(config.instructions).toEqual([".agency/AGENTS.md"])
|
|
48
|
-
expect(config.agent.agency).
|
|
49
|
-
description: expect.stringContaining("Agency workbase orchestration"),
|
|
50
|
-
mode: "subagent",
|
|
51
|
-
prompt: expect.stringContaining("agency context . --json"),
|
|
52
|
-
})
|
|
48
|
+
expect(config.agent.agency).toBeUndefined()
|
|
53
49
|
expect(config.agent.plan).toEqual({ disable: true })
|
|
54
50
|
expect(config.agent["agency-plan"]).toMatchObject({
|
|
55
51
|
mode: "primary",
|
|
@@ -569,6 +569,49 @@ describe("work command", () => {
|
|
|
569
569
|
).rejects.toThrow("Recalled repository conflicts")
|
|
570
570
|
})
|
|
571
571
|
|
|
572
|
+
test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
|
|
573
|
+
const blocked = createHarness({
|
|
574
|
+
workspace: multiPhaseWorkspace,
|
|
575
|
+
multiPhaseTasks: ["example"],
|
|
576
|
+
guardError: new Error("Phase dependency is working"),
|
|
577
|
+
})
|
|
578
|
+
await expect(
|
|
579
|
+
blocked.runPrepare({
|
|
580
|
+
cwd: "/workbase",
|
|
581
|
+
taskId: "example",
|
|
582
|
+
phaseId: "implementation",
|
|
583
|
+
}),
|
|
584
|
+
).rejects.toThrow("Phase dependency is working")
|
|
585
|
+
expect(blocked.events).toEqual(["guard"])
|
|
586
|
+
|
|
587
|
+
const forced = createHarness({
|
|
588
|
+
workspace: multiPhaseWorkspace,
|
|
589
|
+
multiPhaseTasks: ["example"],
|
|
590
|
+
guardError: new Error("Phase dependency is working"),
|
|
591
|
+
})
|
|
592
|
+
await forced.runPrepare({
|
|
593
|
+
cwd: "/workbase",
|
|
594
|
+
taskId: "example",
|
|
595
|
+
phaseId: "implementation",
|
|
596
|
+
force: true,
|
|
597
|
+
silent: true,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
expect(forced.guards).toEqual([
|
|
601
|
+
{
|
|
602
|
+
target: "execution-unit:phase/example/implementation",
|
|
603
|
+
override: true,
|
|
604
|
+
},
|
|
605
|
+
])
|
|
606
|
+
expect(forced.events).toEqual(["guard", "materialize"])
|
|
607
|
+
expect(forced.materializeOptions[0]).toMatchObject({
|
|
608
|
+
force: true,
|
|
609
|
+
validationAlreadyPerformed: true,
|
|
610
|
+
})
|
|
611
|
+
expect(forced.launches).toEqual([])
|
|
612
|
+
expect(forced.statusUpdates).toEqual([])
|
|
613
|
+
})
|
|
614
|
+
|
|
572
615
|
test("launches an epic agent from an epic directory", async () => {
|
|
573
616
|
const harness = createHarness()
|
|
574
617
|
|
package/src/commands/work.ts
CHANGED
|
@@ -577,7 +577,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
577
577
|
|
|
578
578
|
export const help = `
|
|
579
579
|
Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto]
|
|
580
|
-
agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
|
|
580
|
+
agency work prepare [target] [--evidence <json-or-path>] [--force] [--dry-run] [--json]
|
|
581
581
|
|
|
582
582
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
583
583
|
interactively. A positional argument resolves as a directory first, then as a task
|
|
@@ -592,7 +592,8 @@ changes without fetching, creating branches, or creating worktrees.
|
|
|
592
592
|
It emits revision-bound validation evidence and an idempotent external-orchestrator
|
|
593
593
|
contract. Evidence is reused only while the target, workbase, configuration, and
|
|
594
594
|
repository mapping remain unchanged. Dynamic readiness and workspace safety checks
|
|
595
|
-
always run.
|
|
595
|
+
always run. With prepare, --force overrides readiness without launching or changing
|
|
596
|
+
lifecycle status.
|
|
596
597
|
|
|
597
598
|
Options:
|
|
598
599
|
--epic <id> Work on an epic
|
|
@@ -605,8 +606,8 @@ Options:
|
|
|
605
606
|
--print-command Print cwd, argv, and non-secret environment without launch
|
|
606
607
|
--opencode Require the OpenCode preset
|
|
607
608
|
--claude Require the Claude Code preset
|
|
608
|
-
--force Override readiness;
|
|
609
|
-
|
|
609
|
+
--force Override readiness; launched terminal work is reopened
|
|
610
|
+
--evidence <value> Validation evidence JSON or a path to JSON (prepare only)
|
|
610
611
|
--no-input Never open an interactive selector
|
|
611
612
|
|
|
612
613
|
Without interactive input, provide an explicit workbase or cwd and an entity
|
package/src/commands/worktree.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { WorktreeService } from "../services/WorktreeService"
|
|
|
6
6
|
interface WorktreeOptions extends BaseCommandOptions {
|
|
7
7
|
readonly subcommand?: string
|
|
8
8
|
readonly args?: readonly string[]
|
|
9
|
+
readonly force?: boolean
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const targetLabel = (owner: {
|
|
@@ -134,5 +135,6 @@ Options:
|
|
|
134
135
|
--task <id> Select a task without positional IDs
|
|
135
136
|
--phase <id> Select a phase with --task
|
|
136
137
|
--dry-run Preflight and report changes without applying them
|
|
138
|
+
--force Override an existing worktree operation lock
|
|
137
139
|
--json Print structured output
|
|
138
140
|
`
|
|
@@ -186,9 +186,12 @@ describe("IntegrationService", () => {
|
|
|
186
186
|
}
|
|
187
187
|
expect(managedWorkbaseAgents).toContain("agency push --json")
|
|
188
188
|
expect(managedWorkbaseAgents).toContain("agency-execution-v1")
|
|
189
|
-
expect(managedWorkbaseAgents).toContain("agency context . --
|
|
189
|
+
expect(managedWorkbaseAgents).toContain("agency context . --json")
|
|
190
190
|
expect(managedWorkbaseAgents).toContain(
|
|
191
|
-
"
|
|
191
|
+
"Pass `--full` only when document prose or low-level VCS details are needed",
|
|
192
|
+
)
|
|
193
|
+
expect(managedWorkbaseAgents).not.toContain(
|
|
194
|
+
"agency context . --full --json",
|
|
192
195
|
)
|
|
193
196
|
expect(managedWorkbaseAgents).toContain(
|
|
194
197
|
"Never pass `--work` or `--auto` to `agency task create`",
|
|
@@ -681,19 +684,11 @@ describe("IntegrationService", () => {
|
|
|
681
684
|
)
|
|
682
685
|
})
|
|
683
686
|
|
|
684
|
-
test("configures Agency
|
|
687
|
+
test("configures Agency planning with complete workbase access", () => {
|
|
685
688
|
const config = JSON.parse(managedBody(managedWorkbaseOpencode))
|
|
686
689
|
|
|
687
690
|
expect(config.instructions).toEqual([".agency/AGENTS.md"])
|
|
688
691
|
expect(config.agent).toEqual({
|
|
689
|
-
agency: {
|
|
690
|
-
description:
|
|
691
|
-
"Handles Agency workbase orchestration and workflow operations with the Agency CLI",
|
|
692
|
-
mode: "subagent",
|
|
693
|
-
prompt: expect.stringMatching(
|
|
694
|
-
/agency context \. --json[\s\S]+agency work prepare[\s\S]+never pass `--work` or `--auto`/,
|
|
695
|
-
),
|
|
696
|
-
},
|
|
697
692
|
plan: {
|
|
698
693
|
disable: true,
|
|
699
694
|
},
|
|
@@ -716,19 +711,16 @@ describe("IntegrationService", () => {
|
|
|
716
711
|
},
|
|
717
712
|
},
|
|
718
713
|
})
|
|
719
|
-
expect(config.agent.agency.model).toBeUndefined()
|
|
720
|
-
expect(config.agent.agency.permission).toBeUndefined()
|
|
721
714
|
expect(config.agent["agency-plan"].prompt).toContain(
|
|
722
715
|
"Explicit-new intent overrides reuse",
|
|
723
716
|
)
|
|
724
|
-
expect(config.agent.agency
|
|
725
|
-
expect(config.agent.agency.steps).toBeUndefined()
|
|
726
|
-
expect(config.agent.agency.prompt).toContain(
|
|
727
|
-
"Return the prepared execution contract to the caller",
|
|
728
|
-
)
|
|
717
|
+
expect(config.agent.agency).toBeUndefined()
|
|
729
718
|
expect(config.agent["agency-plan"].prompt).toContain(
|
|
730
719
|
"Start with `agency context . --json`",
|
|
731
720
|
)
|
|
721
|
+
expect(config.agent["agency-plan"].prompt).toContain(
|
|
722
|
+
"Pass `--full` only when document prose or low-level VCS details are needed",
|
|
723
|
+
)
|
|
732
724
|
expect(config.agent["agency-plan"].prompt).toContain(
|
|
733
725
|
"decompose it into independently deliverable tasks",
|
|
734
726
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdtemp, readdir, rm } from "node:fs/promises"
|
|
3
|
+
import { mkdtemp, readdir, rm, utimes } from "node:fs/promises"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { join } from "node:path"
|
|
6
6
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
@@ -54,11 +54,90 @@ describe("withWorktreeLocks", () => {
|
|
|
54
54
|
_tag: "Left",
|
|
55
55
|
left: {
|
|
56
56
|
_tag: "WorktreeLockError",
|
|
57
|
-
message: `Another worktree operation is in progress for 'alpha'.
|
|
57
|
+
message: `Another worktree operation is in progress for 'alpha'. Retry with --force or remove the stale sentinel with: rm '${lockPath}'`,
|
|
58
58
|
},
|
|
59
59
|
})
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
+
test("removes stale locks before acquiring them", async () => {
|
|
63
|
+
const root = await createTempDir()
|
|
64
|
+
tempDirs.push(root)
|
|
65
|
+
const lockPath = join(
|
|
66
|
+
root,
|
|
67
|
+
`.agency-worktree-${Buffer.from("alpha:task").toString("hex")}.lock`,
|
|
68
|
+
)
|
|
69
|
+
await Bun.write(lockPath, "")
|
|
70
|
+
const staleAt = new Date(Date.now() - 11 * 60 * 1000)
|
|
71
|
+
await utimes(lockPath, staleAt, staleAt)
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
Effect.runPromise(
|
|
75
|
+
withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
|
|
76
|
+
),
|
|
77
|
+
).resolves.toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("force overrides an active lock", async () => {
|
|
81
|
+
const root = await createTempDir()
|
|
82
|
+
tempDirs.push(root)
|
|
83
|
+
let firstEntered!: () => void
|
|
84
|
+
let firstRelease!: () => void
|
|
85
|
+
const firstEnteredPromise = new Promise<void>((resolve) => {
|
|
86
|
+
firstEntered = resolve
|
|
87
|
+
})
|
|
88
|
+
const firstReleasePromise = new Promise<void>((resolve) => {
|
|
89
|
+
firstRelease = resolve
|
|
90
|
+
})
|
|
91
|
+
const first = Effect.runPromise(
|
|
92
|
+
withWorktreeLocks(
|
|
93
|
+
root,
|
|
94
|
+
[{ taskId: "alpha" }],
|
|
95
|
+
Effect.promise(async () => {
|
|
96
|
+
firstEntered()
|
|
97
|
+
await firstReleasePromise
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
await firstEnteredPromise
|
|
102
|
+
|
|
103
|
+
let forcedEntered!: () => void
|
|
104
|
+
let forcedRelease!: () => void
|
|
105
|
+
const forcedEnteredPromise = new Promise<void>((resolve) => {
|
|
106
|
+
forcedEntered = resolve
|
|
107
|
+
})
|
|
108
|
+
const forcedReleasePromise = new Promise<void>((resolve) => {
|
|
109
|
+
forcedRelease = resolve
|
|
110
|
+
})
|
|
111
|
+
const forced = Effect.runPromise(
|
|
112
|
+
withWorktreeLocks(
|
|
113
|
+
root,
|
|
114
|
+
[{ taskId: "alpha" }],
|
|
115
|
+
Effect.promise(async () => {
|
|
116
|
+
forcedEntered()
|
|
117
|
+
await forcedReleasePromise
|
|
118
|
+
}),
|
|
119
|
+
{ force: true },
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
await forcedEnteredPromise
|
|
123
|
+
|
|
124
|
+
firstRelease()
|
|
125
|
+
await first
|
|
126
|
+
|
|
127
|
+
const conflict = await Effect.runPromise(
|
|
128
|
+
Effect.either(
|
|
129
|
+
withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
expect(conflict).toMatchObject({
|
|
133
|
+
_tag: "Left",
|
|
134
|
+
left: { _tag: "WorktreeLockError" },
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
forcedRelease()
|
|
138
|
+
await forced
|
|
139
|
+
})
|
|
140
|
+
|
|
62
141
|
test("releases locks when the protected operation fails", async () => {
|
|
63
142
|
const root = await createTempDir()
|
|
64
143
|
tempDirs.push(root)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Data, Effect } from "effect"
|
|
2
|
-
import { open, rm } from "node:fs/promises"
|
|
2
|
+
import { open, rm, stat } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
|
|
5
5
|
class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
|
|
@@ -12,10 +12,23 @@ export interface WorktreeLockTarget {
|
|
|
12
12
|
readonly phaseId?: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface WorktreeLockOptions {
|
|
16
|
+
readonly force?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lockTimeoutMs = 10 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
const isErrorCode = (cause: unknown, code: string) =>
|
|
22
|
+
typeof cause === "object" &&
|
|
23
|
+
cause !== null &&
|
|
24
|
+
"code" in cause &&
|
|
25
|
+
cause.code === code
|
|
26
|
+
|
|
15
27
|
const withWorktreeLock = <A, E, R>(
|
|
16
28
|
root: string,
|
|
17
29
|
target: WorktreeLockTarget,
|
|
18
30
|
effect: Effect.Effect<A, E, R>,
|
|
31
|
+
options: WorktreeLockOptions,
|
|
19
32
|
): Effect.Effect<A, E | WorktreeLockError, R> => {
|
|
20
33
|
const key = Buffer.from(
|
|
21
34
|
`${target.taskId}:${target.phaseId ?? "task"}`,
|
|
@@ -24,18 +37,41 @@ const withWorktreeLock = <A, E, R>(
|
|
|
24
37
|
const removalCommand = `rm '${lockPath.replaceAll("'", `'\\''`)}'`
|
|
25
38
|
return Effect.acquireUseRelease(
|
|
26
39
|
Effect.tryPromise({
|
|
27
|
-
try: () =>
|
|
40
|
+
try: async () => {
|
|
41
|
+
try {
|
|
42
|
+
return await open(lockPath, "wx")
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
if (!isErrorCode(cause, "EEXIST")) throw cause
|
|
45
|
+
let stale = false
|
|
46
|
+
try {
|
|
47
|
+
stale = Date.now() - (await stat(lockPath)).mtimeMs >= lockTimeoutMs
|
|
48
|
+
} catch (statCause) {
|
|
49
|
+
if (!isErrorCode(statCause, "ENOENT")) throw statCause
|
|
50
|
+
}
|
|
51
|
+
if (!options.force && !stale) throw cause
|
|
52
|
+
await rm(lockPath, { force: true })
|
|
53
|
+
return open(lockPath, "wx")
|
|
54
|
+
}
|
|
55
|
+
},
|
|
28
56
|
catch: (cause) =>
|
|
29
57
|
new WorktreeLockError({
|
|
30
|
-
message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'.
|
|
58
|
+
message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'. Retry with --force or remove the stale sentinel with: ${removalCommand}`,
|
|
31
59
|
cause,
|
|
32
60
|
}),
|
|
33
61
|
}),
|
|
34
62
|
() => effect,
|
|
35
63
|
(lock) =>
|
|
36
64
|
Effect.promise(async () => {
|
|
65
|
+
let ownsLock = false
|
|
66
|
+
try {
|
|
67
|
+
const [held, current] = await Promise.all([
|
|
68
|
+
lock.stat(),
|
|
69
|
+
stat(lockPath),
|
|
70
|
+
])
|
|
71
|
+
ownsLock = held.dev === current.dev && held.ino === current.ino
|
|
72
|
+
} catch {}
|
|
37
73
|
await lock.close().catch(() => undefined)
|
|
38
|
-
await rm(lockPath, { force: true }).catch(() => undefined)
|
|
74
|
+
if (ownsLock) await rm(lockPath, { force: true }).catch(() => undefined)
|
|
39
75
|
}),
|
|
40
76
|
)
|
|
41
77
|
}
|
|
@@ -44,6 +80,7 @@ export const withWorktreeLocks = <A, E, R>(
|
|
|
44
80
|
root: string,
|
|
45
81
|
targets: readonly WorktreeLockTarget[],
|
|
46
82
|
effect: Effect.Effect<A, E, R>,
|
|
83
|
+
options: WorktreeLockOptions = {},
|
|
47
84
|
): Effect.Effect<A, E | WorktreeLockError, R> => {
|
|
48
85
|
const unique = new Map(
|
|
49
86
|
targets.map((target) => [
|
|
@@ -55,7 +92,7 @@ export const withWorktreeLocks = <A, E, R>(
|
|
|
55
92
|
for (const [, target] of [...unique.entries()]
|
|
56
93
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
57
94
|
.reverse()) {
|
|
58
|
-
current = withWorktreeLock(root, target, current)
|
|
95
|
+
current = withWorktreeLock(root, target, current, options)
|
|
59
96
|
}
|
|
60
97
|
return current
|
|
61
98
|
}
|
|
@@ -259,6 +259,7 @@ interface MaterializeOptions extends BaseCommandOptions {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
interface RemoveOptions extends BaseCommandOptions {
|
|
262
|
+
readonly force?: boolean
|
|
262
263
|
readonly snapshots?: WorktreeRemovalSnapshot[]
|
|
263
264
|
readonly lockHeld?: boolean
|
|
264
265
|
readonly allowReferenceDrift?: boolean
|
|
@@ -267,6 +268,7 @@ interface RemoveOptions extends BaseCommandOptions {
|
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
interface LifecycleOptions extends BaseCommandOptions {
|
|
271
|
+
readonly force?: boolean
|
|
270
272
|
readonly lockHeld?: boolean
|
|
271
273
|
}
|
|
272
274
|
|
|
@@ -1825,6 +1827,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1825
1827
|
root,
|
|
1826
1828
|
[{ taskId, ...(phaseId ? { phaseId } : {}) }],
|
|
1827
1829
|
materialization,
|
|
1830
|
+
{ force: options.force },
|
|
1828
1831
|
)
|
|
1829
1832
|
}),
|
|
1830
1833
|
|
|
@@ -2148,6 +2151,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2148
2151
|
root,
|
|
2149
2152
|
[{ taskId, ...(phaseId ? { phaseId } : {}) }],
|
|
2150
2153
|
removal,
|
|
2154
|
+
{ force: options.force },
|
|
2151
2155
|
)
|
|
2152
2156
|
}),
|
|
2153
2157
|
|
|
@@ -2170,6 +2174,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2170
2174
|
...options,
|
|
2171
2175
|
lockHeld: true,
|
|
2172
2176
|
}),
|
|
2177
|
+
{ force: options.force },
|
|
2173
2178
|
)
|
|
2174
2179
|
}
|
|
2175
2180
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
@@ -2316,6 +2321,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2316
2321
|
...options,
|
|
2317
2322
|
lockHeld: true,
|
|
2318
2323
|
}),
|
|
2324
|
+
{ force: options.force },
|
|
2319
2325
|
)
|
|
2320
2326
|
}
|
|
2321
2327
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -79,10 +79,10 @@ commands without prescribing an execution environment.
|
|
|
79
79
|
Start every session with one read-only command:
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
agency context . --
|
|
82
|
+
agency context . --json
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Pass `--full` only when document prose or low-level VCS details are needed. Use
|
|
86
86
|
the returned target, document paths and revisions, dependency readiness,
|
|
87
87
|
authority, checkout state, PR state, and validation result. Do not infer these
|
|
88
88
|
from directory names or stale prose.
|
|
@@ -8,7 +8,7 @@ const checksum = (content: string) =>
|
|
|
8
8
|
|
|
9
9
|
const agencyPlanPrompt = `You are in Agency Plan mode. Think, read, search, and delegate exploration to construct a well-formed plan for the user's goal. Keep the plan comprehensive but concise, and ask clarifying questions when important tradeoffs or intent are unclear.
|
|
10
10
|
|
|
11
|
-
Start with \`agency context . --json\`. Use its document paths and revisions, then inspect the graph, related epics, tasks, phases, linked tickets, and repository declarations needed to understand the work. Use machine-readable Agency output when available instead of inferring structure from directory names.
|
|
11
|
+
Start with \`agency context . --json\`. Pass \`--full\` only when document prose or low-level VCS details are needed. Use its document paths and revisions, then inspect the graph, related epics, tasks, phases, linked tickets, and repository declarations needed to understand the work. Use machine-readable Agency output when available instead of inferring structure from directory names.
|
|
12
12
|
|
|
13
13
|
When planning an epic, decompose it into independently deliverable tasks with explicit dependencies. Add phases only when one task genuinely requires multiple ordered delivery units. Reuse or update existing work instead of creating duplicate tasks or phases, except when the user explicitly requests a new, separate, or follow-up item. Explicit-new intent overrides reuse of active and archived work even when the subject or suggested ID matches.
|
|
14
14
|
|
|
@@ -20,24 +20,12 @@ const agencyPlanBashPermissions = {
|
|
|
20
20
|
"agency *": "allow",
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const agencyAgentPrompt = `You are the Agency workflow specialist. Use the Agency CLI to handle delegated workbase orchestration and workflow operations. Always start with \`agency context . --json\` and follow the managed Agency instructions and reported authority.
|
|
24
|
-
|
|
25
|
-
When the intent and parameters are known, use the matching managed Command Fast Path exactly. The recipes cover creation, preparation, synchronization, phase conversion, archiving, review work, inspection, dropping, continuation, publication, pull requests, non-PR completion, multi-phase setup, investigation handoff, and review refresh. Preparation recipes call \`agency work prepare <task-or-document> --json\`. Do not probe help or list unrelated state, never pass \`--work\` or \`--auto\` to \`agency task create\`, and use \`--dry-run\` only where the recipe or caller requests it. Use CLI discovery only when no fast-path recipe matches or a prescribed command rejects known-current syntax.
|
|
26
|
-
|
|
27
|
-
Return the prepared execution contract to the caller. Agency owns the target, validation, materialized workspace facts, and native commands; the caller owns presentation and process orchestration.`
|
|
28
|
-
|
|
29
23
|
const body = () =>
|
|
30
24
|
`${JSON.stringify(
|
|
31
25
|
{
|
|
32
26
|
$schema: "https://opencode.ai/config.json",
|
|
33
27
|
instructions: [".agency/AGENTS.md"],
|
|
34
28
|
agent: {
|
|
35
|
-
agency: {
|
|
36
|
-
description:
|
|
37
|
-
"Handles Agency workbase orchestration and workflow operations with the Agency CLI",
|
|
38
|
-
mode: "subagent",
|
|
39
|
-
prompt: agencyAgentPrompt,
|
|
40
|
-
},
|
|
41
29
|
plan: {
|
|
42
30
|
disable: true,
|
|
43
31
|
},
|