@markjaquith/agency 2.29.0 → 2.30.1

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.
Files changed (50) hide show
  1. package/README.md +72 -16
  2. package/cli.ts +3 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +30 -15
  6. package/skills/agency/references/commands.md +38 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +52 -12
  9. package/src/cli-parser.test.ts +7 -0
  10. package/src/cli-parser.ts +13 -2
  11. package/src/cli.test.ts +422 -3
  12. package/src/commands/doctor.test.ts +22 -0
  13. package/src/commands/init.test.ts +3 -2
  14. package/src/commands/integration.test.ts +21 -1
  15. package/src/commands/integration.ts +8 -4
  16. package/src/commands/pr.test.ts +20 -1
  17. package/src/commands/repo.test.ts +66 -1
  18. package/src/commands/repo.ts +35 -8
  19. package/src/commands/status.test.ts +22 -0
  20. package/src/commands/status.ts +1 -0
  21. package/src/commands/sync.ts +5 -3
  22. package/src/commands/work.test.ts +74 -1
  23. package/src/commands/work.ts +26 -3
  24. package/src/graph-schema.test.ts +52 -4
  25. package/src/protocol.test.ts +41 -8
  26. package/src/readiness.test.ts +75 -17
  27. package/src/services/DoctorService.ts +32 -18
  28. package/src/services/EpicService.ts +1 -1
  29. package/src/services/GraphMutationService.ts +3 -3
  30. package/src/services/GraphService.ts +13 -4
  31. package/src/services/IntegrationService.test.ts +37 -12
  32. package/src/services/IntegrationService.ts +70 -21
  33. package/src/services/PhaseService.ts +1 -1
  34. package/src/services/ReadinessService.test.ts +47 -0
  35. package/src/services/RepositoryService.test.ts +299 -5
  36. package/src/services/RepositoryService.ts +725 -98
  37. package/src/services/SyncService.test.ts +36 -0
  38. package/src/services/SyncService.ts +20 -1
  39. package/src/services/TaskService.ts +1 -1
  40. package/src/services/WorkbaseService.test.ts +32 -0
  41. package/src/services/WorkbaseService.ts +22 -14
  42. package/src/services/WorktreeLock.test.ts +122 -0
  43. package/src/services/WorktreeService.test.ts +17 -2
  44. package/src/services/WorktreeService.ts +3 -3
  45. package/src/utils/process.test.ts +4 -3
  46. package/src/workbase/AGENTS.md +9 -1
  47. package/src/workbase/dependency-graph.test.ts +50 -0
  48. package/src/workbase/opencode-file.ts +3 -13
  49. package/src/workbase/schemas.test.ts +52 -0
  50. package/src/workbase/schemas.ts +19 -0
@@ -68,6 +68,42 @@ JSON
68
68
  await cleanupTempDir(root)
69
69
  })
70
70
 
71
+ test("validates before applying repository setup", async () => {
72
+ await rm(join(root, "repos/agency"), { recursive: true, force: true })
73
+ await Bun.write(
74
+ join(root, "agency.json"),
75
+ JSON.stringify({
76
+ version: 2,
77
+ repositories: {
78
+ agency: { remote: "https://example.com/agency.git" },
79
+ },
80
+ }),
81
+ )
82
+ await mkdir(join(root, "tasks/invalid"), { recursive: true })
83
+ await Bun.write(
84
+ join(root, "tasks/invalid/TASK.md"),
85
+ `---
86
+ ticketUrl: null
87
+ repo: unknown
88
+ branch: task/invalid
89
+ base: main
90
+ pr: null
91
+ ---
92
+ `,
93
+ )
94
+
95
+ await expect(
96
+ runTestEffect(
97
+ SyncService.pipe(
98
+ Effect.flatMap((service) =>
99
+ service.reconcile({ cwd: root, apply: true }),
100
+ ),
101
+ ),
102
+ ),
103
+ ).rejects.toThrow("Unknown repository alias 'unknown'")
104
+ expect(await Bun.file(join(root, "repos/agency")).exists()).toBe(false)
105
+ })
106
+
71
107
  test("observes drift without mutation and applies only safe transitions", async () => {
72
108
  await runTestEffect(
73
109
  TaskService.pipe(
@@ -21,6 +21,10 @@ import { PhaseService } from "./PhaseService"
21
21
  import { TaskService } from "./TaskService"
22
22
  import { WorkbaseService } from "./WorkbaseService"
23
23
  import { WorktreeService } from "./WorktreeService"
24
+ import {
25
+ RepositoryService,
26
+ type RepositorySetupResult,
27
+ } from "./RepositoryService"
24
28
 
25
29
  class SyncError extends Data.TaggedError("SyncError")<{
26
30
  readonly message: string
@@ -93,6 +97,7 @@ interface SyncResult {
93
97
  readonly warnings: readonly SyncNotice[]
94
98
  readonly unresolved: readonly SyncNotice[]
95
99
  readonly executions: readonly ExecutionSyncState[]
100
+ readonly repositories: RepositorySetupResult
96
101
  }
97
102
 
98
103
  const parseWorktrees = (output: string): RegisteredWorktree[] => {
@@ -150,6 +155,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
150
155
  const phases = yield* PhaseService
151
156
  const worktrees = yield* WorktreeService
152
157
  const claims = yield* ClaimService
158
+ const repositories = yield* RepositoryService
153
159
  const { root, config } = yield* workbase.loadConfig(options.cwd)
154
160
  const validation = yield* workbase.validate(root)
155
161
  if (!validation.valid) {
@@ -159,12 +165,24 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
159
165
  .join("\n"),
160
166
  })
161
167
  }
168
+ const repositorySetup = yield* repositories.setup({
169
+ cwd: root,
170
+ apply: options.apply === true,
171
+ })
162
172
 
163
173
  const apply = options.apply === true
164
174
  const now = options.now ?? new Date()
165
175
  const changes: SyncChange[] = []
166
176
  const warnings: SyncNotice[] = []
167
177
  const unresolved: SyncNotice[] = []
178
+ for (const issue of repositorySetup.unresolved) {
179
+ unresolved.push({
180
+ kind: `repository-${issue.state}`,
181
+ target: `repository:${issue.alias}`,
182
+ message: issue.message,
183
+ action: issue.action,
184
+ })
185
+ }
168
186
  const executions: ExecutionSyncState[] = []
169
187
  const runExternal = (
170
188
  args: readonly string[],
@@ -240,7 +258,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
240
258
  kind: "missing-repository",
241
259
  target: record.key,
242
260
  message: `Repository alias '${checkout.repo}' is missing`,
243
- action: "Restore or relink the repository alias",
261
+ action: "Run 'agency repo setup --apply' or relink the alias",
244
262
  })
245
263
  workspaceConflict = true
246
264
  continue
@@ -822,6 +840,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
822
840
  warnings,
823
841
  unresolved,
824
842
  executions,
843
+ repositories: repositorySetup,
825
844
  } satisfies SyncResult
826
845
  }),
827
846
  }),
@@ -140,7 +140,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
140
140
  })
141
141
  }
142
142
  for (const alias of referencedRepos) {
143
- if (!(yield* fs.exists(join(root, "repos", alias)))) {
143
+ if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
144
144
  return yield* new TaskError({
145
145
  message: `Unknown repository alias '${alias}'`,
146
146
  })
@@ -41,6 +41,38 @@ describe("WorkbaseService", () => {
41
41
  ).toBe(false)
42
42
  })
43
43
 
44
+ test("treats declared but missing repositories as valid aliases", async () => {
45
+ await write(
46
+ root,
47
+ "agency.json",
48
+ JSON.stringify({
49
+ version: 2,
50
+ repositories: {
51
+ agency: { remote: "https://example.com/agency.git" },
52
+ },
53
+ }),
54
+ )
55
+ await write(
56
+ root,
57
+ "tasks/portable/TASK.md",
58
+ `---
59
+ ticketUrl: null
60
+ repo: agency
61
+ branch: task/portable
62
+ base: main
63
+ pr: null
64
+ ---
65
+ `,
66
+ )
67
+
68
+ const report = await runTestEffect(
69
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
70
+ )
71
+
72
+ expect(report.valid).toBe(true)
73
+ expect(report.issues).toEqual([])
74
+ })
75
+
44
76
  test("registers canonical workbase paths without duplicates", async () => {
45
77
  const workbaseRoot = join(root, "workbase")
46
78
  const nested = join(workbaseRoot, "nested")
@@ -323,6 +323,27 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
323
323
  return { root, config: decoded.value }
324
324
  }),
325
325
 
326
+ repositoryAliases: (startPath: string = process.cwd()) =>
327
+ Effect.gen(function* () {
328
+ const service = yield* WorkbaseService
329
+ const fs = yield* FileSystemService
330
+ const { root, config } = yield* service.loadConfig(startPath)
331
+ const aliases = new Set(Object.keys(config.repositories ?? {}))
332
+ const reposPath = join(root, "repos")
333
+ if (yield* fs.isDirectory(reposPath)) {
334
+ for (const entry of yield* fs.readDirectory(reposPath)) {
335
+ if (!entry.name.startsWith(".agency-")) aliases.add(entry.name)
336
+ }
337
+ }
338
+ return [...aliases].sort()
339
+ }),
340
+
341
+ hasRepositoryAlias: (alias: string, startPath: string = process.cwd()) =>
342
+ WorkbaseService.pipe(
343
+ Effect.flatMap((service) => service.repositoryAliases(startPath)),
344
+ Effect.map((aliases) => aliases.includes(alias)),
345
+ ),
346
+
326
347
  register: (startPath: string, configDirectory?: string, name?: string) =>
327
348
  Effect.gen(function* () {
328
349
  const service = yield* WorkbaseService
@@ -584,20 +605,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
584
605
  .sort()
585
606
  })
586
607
 
587
- const readAliases = Effect.gen(function* () {
588
- const reposPath = join(root, "repos")
589
- if (!(yield* fs.isDirectory(reposPath))) {
590
- return new Set<string>()
591
- }
592
- const entries = yield* fs.readDirectory(reposPath)
593
- return new Set(
594
- entries
595
- .filter((entry) => entry.isDirectory || entry.isSymlink)
596
- .map((entry) => entry.name),
597
- )
598
- })
599
-
600
- const aliases = yield* readAliases
608
+ const aliases = new Set(yield* service.repositoryAliases(root))
601
609
 
602
610
  const readDocument = <S extends Schema.Schema.AnyNoContext>(
603
611
  path: string,
@@ -0,0 +1,122 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdtemp, readdir, rm } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { withWorktreeLocks } from "./WorktreeLock"
7
+
8
+ const createTempDir = () => mkdtemp(join(tmpdir(), "agency-lock-test-"))
9
+ const cleanupTempDir = (path: string) =>
10
+ rm(path, { recursive: true, force: true })
11
+
12
+ describe("withWorktreeLocks", () => {
13
+ const tempDirs: string[] = []
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(tempDirs.splice(0).map(cleanupTempDir))
17
+ })
18
+
19
+ test("rejects a concurrent operation for the same target", async () => {
20
+ const root = await createTempDir()
21
+ tempDirs.push(root)
22
+ let entered!: () => void
23
+ let release!: () => void
24
+ const enteredPromise = new Promise<void>((resolve) => {
25
+ entered = resolve
26
+ })
27
+ const releasePromise = new Promise<void>((resolve) => {
28
+ release = resolve
29
+ })
30
+ const held = Effect.runPromise(
31
+ withWorktreeLocks(
32
+ root,
33
+ [{ taskId: "alpha" }],
34
+ Effect.promise(async () => {
35
+ entered()
36
+ await releasePromise
37
+ }),
38
+ ),
39
+ )
40
+ await enteredPromise
41
+
42
+ const conflict = await Effect.runPromise(
43
+ Effect.either(
44
+ withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
45
+ ),
46
+ )
47
+ release()
48
+ await held
49
+ expect(conflict).toMatchObject({
50
+ _tag: "Left",
51
+ left: {
52
+ _tag: "WorktreeLockError",
53
+ message: "Another worktree operation is in progress for 'alpha'",
54
+ },
55
+ })
56
+ })
57
+
58
+ test("releases locks when the protected operation fails", async () => {
59
+ const root = await createTempDir()
60
+ tempDirs.push(root)
61
+ const failure = new Error("operation failed")
62
+
63
+ const result = await Effect.runPromise(
64
+ Effect.either(
65
+ withWorktreeLocks(
66
+ root,
67
+ [{ taskId: "alpha", phaseId: "build" }],
68
+ Effect.fail(failure),
69
+ ),
70
+ ),
71
+ )
72
+ expect(result._tag).toBe("Left")
73
+ if (result._tag === "Left") expect(result.left).toBe(failure)
74
+ await expect(
75
+ Effect.runPromise(
76
+ withWorktreeLocks(
77
+ root,
78
+ [{ taskId: "alpha", phaseId: "build" }],
79
+ Effect.succeed("reacquired"),
80
+ ),
81
+ ),
82
+ ).resolves.toBe("reacquired")
83
+ })
84
+
85
+ test("deduplicates targets while keeping task and phase locks distinct", async () => {
86
+ const root = await createTempDir()
87
+ tempDirs.push(root)
88
+ let entered!: () => void
89
+ let release!: () => void
90
+ const enteredPromise = new Promise<void>((resolve) => {
91
+ entered = resolve
92
+ })
93
+ const releasePromise = new Promise<void>((resolve) => {
94
+ release = resolve
95
+ })
96
+ const held = Effect.runPromise(
97
+ withWorktreeLocks(
98
+ root,
99
+ [
100
+ { taskId: "alpha" },
101
+ { taskId: "alpha" },
102
+ { taskId: "alpha", phaseId: "build" },
103
+ ],
104
+ Effect.promise(async () => {
105
+ entered()
106
+ await releasePromise
107
+ }),
108
+ ),
109
+ )
110
+ await enteredPromise
111
+
112
+ expect(
113
+ (await readdir(root)).filter((path) => path.endsWith(".lock")),
114
+ ).toHaveLength(2)
115
+
116
+ release()
117
+ await held
118
+ expect(
119
+ (await readdir(root)).filter((path) => path.endsWith(".lock")),
120
+ ).toEqual([])
121
+ })
122
+ })
@@ -26,8 +26,16 @@ const git = async (args: string[], cwd?: string) => {
26
26
  describe("WorktreeService", () => {
27
27
  let root: string
28
28
  let source: string
29
+ let effectRepositoryInitialized: boolean
30
+
31
+ const ensureEffectRepository = async () => {
32
+ if (effectRepositoryInitialized) return
33
+ await git(["clone", "--bare", source, join(root, "repos/effect")])
34
+ effectRepositoryInitialized = true
35
+ }
29
36
 
30
37
  beforeEach(async () => {
38
+ effectRepositoryInitialized = false
31
39
  root = await createTempDir()
32
40
  await Bun.write(join(root, "agency.json"), '{"version":2}\n')
33
41
  source = join(root, "source")
@@ -40,12 +48,12 @@ describe("WorktreeService", () => {
40
48
  await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
41
49
  await mkdir(join(root, "repos"), { recursive: true })
42
50
  await git(["clone", "--bare", source, join(root, "repos/agency")])
43
- await git(["clone", "--bare", source, join(root, "repos/effect")])
44
51
  })
45
52
 
46
53
  afterEach(async () => cleanupTempDir(root))
47
54
 
48
55
  test("materializes writable and reference worktrees", async () => {
56
+ await ensureEffectRepository()
49
57
  await runTestEffect(
50
58
  TaskService.pipe(
51
59
  Effect.flatMap((service) =>
@@ -127,6 +135,7 @@ describe("WorktreeService", () => {
127
135
  })
128
136
 
129
137
  test("reuses an immutable reference checkout without fetching", async () => {
138
+ await ensureEffectRepository()
130
139
  const commit = new TextDecoder()
131
140
  .decode(
132
141
  Bun.spawnSync(
@@ -393,7 +402,7 @@ describe("WorktreeService", () => {
393
402
  {
394
403
  id: "single",
395
404
  ticketUrl: "https://example.com/task",
396
- repo: "effect",
405
+ repo: "agency",
397
406
  branch: "task/single",
398
407
  base: "main",
399
408
  },
@@ -760,6 +769,7 @@ pr: null
760
769
  })
761
770
 
762
771
  test("fetches a moving ref before checking a reused reference checkout", async () => {
772
+ await ensureEffectRepository()
763
773
  await runTestEffect(
764
774
  TaskService.pipe(
765
775
  Effect.flatMap((service) =>
@@ -801,6 +811,7 @@ pr: null
801
811
  })
802
812
 
803
813
  test("rejects a reference checkout attached to a branch", async () => {
814
+ await ensureEffectRepository()
804
815
  await runTestEffect(
805
816
  TaskService.pipe(
806
817
  Effect.flatMap((service) =>
@@ -858,6 +869,7 @@ pr: null
858
869
  })
859
870
 
860
871
  test("removes worktrees without deleting branches", async () => {
872
+ await ensureEffectRepository()
861
873
  await runTestEffect(
862
874
  TaskService.pipe(
863
875
  Effect.flatMap((service) =>
@@ -924,6 +936,7 @@ pr: null
924
936
  })
925
937
 
926
938
  test("reports dry-run fetch and worktree changes without mutating", async () => {
939
+ await ensureEffectRepository()
927
940
  await runTestEffect(
928
941
  TaskService.pipe(
929
942
  Effect.flatMap((service) =>
@@ -983,6 +996,7 @@ pr: null
983
996
  })
984
997
 
985
998
  test("dry-run resolves a reference that exists only on the remote", async () => {
999
+ await ensureEffectRepository()
986
1000
  await git(["checkout", "-b", "remote-only"], source)
987
1001
  await Bun.write(join(source, "remote.txt"), "remote\n")
988
1002
  await git(["add", "remote.txt"], source)
@@ -1316,6 +1330,7 @@ pr: null
1316
1330
  })
1317
1331
 
1318
1332
  test("dry-runs and rebuilds every clean declared checkout", async () => {
1333
+ await ensureEffectRepository()
1319
1334
  await runTestEffect(
1320
1335
  TaskService.pipe(
1321
1336
  Effect.flatMap((service) =>
@@ -277,7 +277,7 @@ const inspectExecution = (
277
277
  if (!(yield* fs.exists(repositoryPath))) {
278
278
  conflict(
279
279
  "missing-repository",
280
- `Repository alias '${checkout.repo}' does not exist`,
280
+ `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
281
281
  )
282
282
  checkouts.push({
283
283
  repo: checkout.repo,
@@ -654,7 +654,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
654
654
  const checkoutPath = join(codePath, alias)
655
655
  if (!(yield* fs.exists(repositoryPath))) {
656
656
  return yield* new WorktreeError({
657
- message: `Repository alias '${alias}' does not exist`,
657
+ message: `Repository alias '${alias}' is not materialized; run 'agency repo setup --apply'`,
658
658
  })
659
659
  }
660
660
  const listed = yield* fs.runCommand(
@@ -861,7 +861,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
861
861
  const checkoutPath = join(codePath, alias)
862
862
  if (!(yield* fs.exists(repositoryPath))) {
863
863
  return yield* new WorktreeError({
864
- message: `Repository alias '${alias}' does not exist`,
864
+ message: `Repository alias '${alias}' is not materialized; run 'agency repo setup --apply'`,
865
865
  })
866
866
  }
867
867
 
@@ -42,9 +42,10 @@ describe("spawnProcess", () => {
42
42
 
43
43
  test("captures large stdout and stderr without hanging", async () => {
44
44
  const line = "x".repeat(4096)
45
+ const lineCount = 256
45
46
  const script = [
46
47
  `const line = ${JSON.stringify(line)}`,
47
- "for (let i = 0; i < 2000; i++) {",
48
+ `for (let i = 0; i < ${lineCount}; i++) {`,
48
49
  "console.log(`out:${i}:${line}`)",
49
50
  "console.error(`err:${i}:${line}`)",
50
51
  "}",
@@ -56,8 +57,8 @@ describe("spawnProcess", () => {
56
57
 
57
58
  expect(result.exitCode).toBe(0)
58
59
  expect(result.stdout).toContain("out:0:")
59
- expect(result.stdout).toContain("out:1999:")
60
+ expect(result.stdout).toContain(`out:${lineCount - 1}:`)
60
61
  expect(result.stderr).toContain("err:0:")
61
- expect(result.stderr).toContain("err:1999:")
62
+ expect(result.stderr).toContain(`err:${lineCount - 1}:`)
62
63
  })
63
64
  })
@@ -16,6 +16,11 @@ Use the returned target, document paths and revisions, dependency readiness,
16
16
  authority, checkout state, PR state, and validation result. Do not infer these
17
17
  from directory names or stale prose.
18
18
 
19
+ If context or doctor reports a declared but missing repository, run
20
+ `agency repo setup --dry-run` and obtain explicit approval before
21
+ `agency repo setup --apply`. Missing declared aliases are setup state, not a
22
+ reason to edit `agency.json` or `repos/` by hand.
23
+
19
24
  ## Authority
20
25
 
21
26
  - An epic or multi-phase task is orchestration context and has no implementation
@@ -68,4 +73,7 @@ creates these files, and `agency work` reconciles them before launching an agent
68
73
 
69
74
  OpenCode can access the complete workbase tree, but this filesystem permission
70
75
  does not expand Agency write authority beyond the checkout reported by
71
- `agency context`.
76
+ `agency context`. Agency launches expose the managed OpenCode config file at
77
+ runtime and inject absolute access rules scoped to this workbase. Direct edits
78
+ remain denied outside the writable checkout, and no machine-specific path is
79
+ stored in the Git-synced config.
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { findDependencyCycles, validateDependencies } from "./dependency-graph"
3
+
4
+ describe("dependency graph", () => {
5
+ test("accepts empty and acyclic dependency graphs", () => {
6
+ expect(validateDependencies([], "Tasks")).toBeUndefined()
7
+ expect(
8
+ validateDependencies(
9
+ [
10
+ { id: "build" },
11
+ { id: "test", dependsOn: ["build"] },
12
+ { id: "ship", dependsOn: ["build", "test"] },
13
+ ],
14
+ "Tasks",
15
+ ),
16
+ ).toBeUndefined()
17
+ expect(
18
+ findDependencyCycles([
19
+ { id: "build" },
20
+ { id: "test", dependsOn: ["build"] },
21
+ ]),
22
+ ).toEqual([])
23
+ })
24
+
25
+ test("reports duplicate, self, and unknown dependencies precisely", () => {
26
+ expect(validateDependencies([{ id: "one" }, { id: "one" }], "Tasks")).toBe(
27
+ "Tasks IDs must be unique",
28
+ )
29
+ expect(
30
+ validateDependencies([{ id: "one", dependsOn: ["one"] }], "Tasks"),
31
+ ).toBe("Task 'one' cannot depend on itself")
32
+ expect(
33
+ validateDependencies([{ id: "one", dependsOn: ["missing"] }], "Phases"),
34
+ ).toBe("Unknown phase dependency 'missing'")
35
+ })
36
+
37
+ test("detects cycles deterministically across disconnected graphs", () => {
38
+ const nodes = [
39
+ { id: "delta", dependsOn: ["charlie"] },
40
+ { id: "bravo", dependsOn: ["alpha"] },
41
+ { id: "charlie", dependsOn: ["delta"] },
42
+ { id: "alpha", dependsOn: ["bravo"] },
43
+ ]
44
+
45
+ expect(findDependencyCycles(nodes)).toEqual(["bravo", "delta"])
46
+ expect(validateDependencies(nodes, "Tasks")).toBe(
47
+ "Task dependency cycle includes 'bravo'",
48
+ )
49
+ })
50
+ })
@@ -11,20 +11,10 @@ const body = () =>
11
11
  {
12
12
  $schema: "https://opencode.ai/config.json",
13
13
  references: {
14
- tasks: {
15
- path: "../tasks",
14
+ workbase: {
15
+ path: "..",
16
16
  description:
17
- "Agency task definitions and execution context; authority still comes from agency context",
18
- },
19
- epics: {
20
- path: "../epics",
21
- description:
22
- "Agency epic definitions and orchestration context; no implementation write authority",
23
- },
24
- },
25
- permission: {
26
- external_directory: {
27
- "../**": "allow",
17
+ "Complete Agency workbase context; write authority still comes only from agency context",
28
18
  },
29
19
  },
30
20
  },
@@ -9,8 +9,60 @@ import {
9
9
  WorkStatus,
10
10
  WorkbaseConfig,
11
11
  WorkbaseRegistry,
12
+ RepositoryRemote,
12
13
  } from "./schemas"
13
14
 
15
+ describe("portable repository declarations", () => {
16
+ test("accepts provider-neutral network Git remotes", () => {
17
+ for (const remote of [
18
+ "https://example.com/team/repository.git",
19
+ "ssh://git@example.com/team/repository.git",
20
+ "git://example.com/team/repository.git",
21
+ "git@example.com:team/repository.git",
22
+ "ssh://[2001:db8::1]/team/repository.git",
23
+ ]) {
24
+ expect(Schema.decodeUnknownSync(RepositoryRemote)(remote)).toBe(remote)
25
+ }
26
+ })
27
+
28
+ test("rejects local paths, unsupported protocols, and credential-bearing URLs", () => {
29
+ for (const remote of [
30
+ "/Users/person/repository.git",
31
+ "../repository.git",
32
+ "file:///tmp/repository.git",
33
+ "https://token@example.com/team/repository.git",
34
+ "https://user:password@example.com/team/repository.git",
35
+ "ssh://user:password@example.com/team/repository.git",
36
+ "ftp://example.com/team/repository.git",
37
+ "custom://example.com/team/repository.git",
38
+ "C:/repository.git",
39
+ "https://example.com/repository.git?token=secret",
40
+ "ext::printf",
41
+ "foo::bar",
42
+ "-host:repository.git",
43
+ "-oProxyCommand=x@host:repository.git",
44
+ ]) {
45
+ expect(() => Schema.decodeUnknownSync(RepositoryRemote)(remote)).toThrow()
46
+ }
47
+ })
48
+
49
+ test("decodes repository declarations in version 2 configs", () => {
50
+ expect(
51
+ Schema.decodeUnknownSync(WorkbaseConfig)({
52
+ version: 2,
53
+ repositories: {
54
+ agency: { remote: "https://example.com/agency.git" },
55
+ },
56
+ }),
57
+ ).toEqual({
58
+ version: 2,
59
+ repositories: {
60
+ agency: { remote: "https://example.com/agency.git" },
61
+ },
62
+ })
63
+ })
64
+ })
65
+
14
66
  describe("body-of-work descriptions", () => {
15
67
  test("accepts descriptions on epics, tasks, and phases", () => {
16
68
  const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
@@ -13,6 +13,19 @@ export const EntityId = NonEmptyString.pipe(Schema.pattern(IdPattern))
13
13
 
14
14
  export const RepositoryAlias = NonEmptyString.pipe(Schema.pattern(IdPattern))
15
15
 
16
+ // Portable declarations must be usable after cloning the workbase elsewhere.
17
+ // Local paths, file URLs, and credential-bearing HTTP URLs are intentionally
18
+ // excluded; SSH usernames are identities and remain supported.
19
+ export const RepositoryRemote = NonEmptyString.pipe(
20
+ Schema.pattern(
21
+ /^(?!-)(?![a-zA-Z]:[\\/])(?!https?:\/\/[^/@\s]+@)(?![a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/@\s]*:[^/@\s]*@)(?:(?:https?|ssh|git):\/\/[^\s?#]+|(?![^\s]*::)(?:[^@\s/:]+@)?[a-zA-Z0-9_.][^@\s/:]*:(?!\/\/)[^\s?#]+)$/,
22
+ ),
23
+ )
24
+
25
+ export const RepositoryDeclaration = Schema.Struct({
26
+ remote: RepositoryRemote,
27
+ })
28
+
16
29
  export const RepositoryReference = Schema.Struct({
17
30
  repo: RepositoryAlias,
18
31
  ref: NonEmptyString,
@@ -73,6 +86,9 @@ const DeliveryProvider = Schema.Struct({
73
86
 
74
87
  export const WorkbaseConfig = Schema.Struct({
75
88
  version: Schema.Literal(2),
89
+ repositories: Schema.optional(
90
+ Schema.Record({ key: RepositoryAlias, value: RepositoryDeclaration }),
91
+ ),
76
92
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
77
93
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
78
94
  runners: Schema.optional(
@@ -160,6 +176,9 @@ export type WorkbaseRegistration = Schema.Schema.Type<
160
176
  >
161
177
  export type Dependency = Schema.Schema.Type<typeof Dependency>
162
178
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
179
+ export type RepositoryDeclaration = Schema.Schema.Type<
180
+ typeof RepositoryDeclaration
181
+ >
163
182
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
164
183
  export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
165
184
  export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>