@markjaquith/agency 2.71.27 → 2.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -441,7 +441,7 @@ variables. A worker must verify either signal against `agency context . --json`
441
441
  before acting; a matching worker performs the task directly and must not invoke
442
442
  `agency work` for the same target. Managed guidance also fails safe for older
443
443
  generated prompts when their document paths, current directory, and active valid
444
- context all agree. Herdr is not part of this identity contract.
444
+ context all agree. External session state is not part of this identity contract.
445
445
  The managed OpenCode plugin validates the marker against Agency context, binds it
446
446
  to the receiving OpenCode session, injects an explicit active-worker system
447
447
  instruction, and restores Agency identity for that session's shell environment.
@@ -752,6 +752,7 @@ agency integration sync [--json]
752
752
  agency repo setup [--dry-run | --apply] [--json]
753
753
  agency repo add <alias> <remote> [--json]
754
754
  agency repo link <alias> <path> [--json]
755
+ agency repo materialize <alias> [--json]
755
756
  agency repo list [--json]
756
757
  agency repo show <alias> [--json]
757
758
  agency repo fetch <alias> [--json]
@@ -784,7 +785,11 @@ Each registration has a stable ID and may have a unique name. A default workbase
784
785
  is used when the current directory is outside every workbase. `prune` removes
785
786
  registrations whose workbase configuration no longer exists.
786
787
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
787
- repository. Alias names are then used by all documents and commands. Remove,
788
+ repository. For Git workbases, `repo materialize` replaces a linked alias with a
789
+ managed bare clone while migrating its registered worktrees in place; active
790
+ Agency references continue to use the same alias. The command refuses remote
791
+ drift and stale worktree registrations. Alias names are then used by all
792
+ documents and commands. Remove,
788
793
  unlink, and rename refuse aliases referenced by active work or backed by linked
789
794
  worktrees, and report each blocker.
790
795
 
@@ -855,13 +860,13 @@ includes `selector`, absolute `documentPath`, the document `revision`, full
855
860
  `validation`, normalized `recalledContext`, and `evidence`. Evidence version 1 is
856
861
  an auditable local payload containing the canonical workbase root, target and
857
862
  document identities, aggregate workbase revision, configuration revision,
858
- repository-mapping revision, kickoff-contract version, validity result, recalled
863
+ repository-mapping revision, execution-contract version, validity result, recalled
859
864
  context, and a digest over those fields. It is not a signature or an authority
860
865
  grant. A different workbase, target, document revision, document set,
861
866
  configuration, repository mapping, contract version, or payload digest
862
867
  invalidates reuse. Older creation output without evidence remains compatible;
863
868
  preflight simply validates again. The published machine schema is
864
- `schemas/agency-kickoff-v1.schema.json`.
869
+ `schemas/agency-execution-v1.schema.json`.
865
870
 
866
871
  Create investigation-only work with the existing task architecture:
867
872
 
@@ -1123,12 +1128,18 @@ promptless by default; use `--auto` to send Agency's generated context prompt.
1123
1128
  `agency work prepare` resolves an execution unit and creates or reuses its
1124
1129
  writable and reference worktrees, or its single pinned review checkout, without
1125
1130
  launching an agent or changing status.
1131
+ Managed workbase guidance provides exact fast paths for 15 common intents:
1132
+ single-phase creation; create-and-start; materialization; remote PR sync; phase
1133
+ conversion; archive; review creation and start; status inspection; drop;
1134
+ continuation; publication and PR creation; non-PR completion; initial multi-phase
1135
+ setup; investigation handoff; and review refresh.
1126
1136
  Its JSON result includes the workspace, validation result, whether supplied
1127
1137
  evidence was `reused` or `refreshed` with stable reason strings, refreshed
1128
- evidence, and a versioned `agency-kickoff-v1` orchestration plan. The plan has a
1129
- deterministic idempotency key and ordered, retry-safe actions for worktree
1130
- preflight/preparation, a background tab, side-by-side task document,
1131
- `agency work . --auto`, and exactly one final `agency context <document> --json`.
1138
+ evidence, and a versioned `agency-execution-v1` contract. The contract reports
1139
+ whether preparation was previewed or applied, a stable target identity, prepared
1140
+ workspace and document paths, and canonical Agency-native commands for starting
1141
+ work and reading context. It does not prescribe how callers present or execute
1142
+ prepared work.
1132
1143
  The evidence argument may be an evidence object, task-creation JSON, or a path to
1133
1144
  either. Use `--dry-run` to report planned fetch, branch, and worktree changes
1134
1145
  without applying them. Validation reuse never skips readiness, active-claim,
@@ -1137,7 +1148,7 @@ checks.
1137
1148
 
1138
1149
  The authoritative implementation locations for this contract are
1139
1150
  `src/commands/task.ts` (creation output),
1140
- `src/workbase/kickoff-contract.ts` (evidence and orchestration schemas),
1151
+ `src/workbase/execution-contract.ts` (evidence and execution schemas),
1141
1152
  `src/commands/work.ts` (launch preflight),
1142
1153
  `src/services/WorktreeService.ts` (workspace safety), and
1143
1154
  `src/workbase/AGENTS.md` (generated OpenCode guidance). These paths are the
package/index.ts CHANGED
@@ -2,4 +2,4 @@ export * from "./src/workbase/schemas"
2
2
  export * from "./src/protocol"
3
3
  export * from "./src/graph-schema"
4
4
  export * from "./src/readiness"
5
- export * from "./src/workbase/kickoff-contract"
5
+ export * from "./src/workbase/execution-contract"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.71.27",
3
+ "version": "2.73.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -46,8 +46,8 @@
46
46
  "./schemas/agency-graph-v1.json": {
47
47
  "default": "./schemas/agency-graph-v1.schema.json"
48
48
  },
49
- "./schemas/agency-kickoff-v1.json": {
50
- "default": "./schemas/agency-kickoff-v1.schema.json"
49
+ "./schemas/agency-execution-v1.json": {
50
+ "default": "./schemas/agency-execution-v1.schema.json"
51
51
  },
52
52
  "./fixtures/protocol/success.json": {
53
53
  "default": "./fixtures/protocol/success.json"
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://github.com/markjaquith/agency/schemas/agency-kickoff-v1.schema.json",
4
- "title": "Agency kickoff contract v1",
3
+ "$id": "https://github.com/markjaquith/agency/schemas/agency-execution-v1.schema.json",
4
+ "title": "Agency execution contract v1",
5
5
  "oneOf": [
6
6
  { "$ref": "#/$defs/validationEvidence" },
7
- { "$ref": "#/$defs/kickoffPlan" }
7
+ { "$ref": "#/$defs/executionContract" }
8
8
  ],
9
9
  "$defs": {
10
10
  "recalledContext": {
@@ -54,69 +54,93 @@
54
54
  },
55
55
  "additionalProperties": false
56
56
  },
57
- "kickoffPlan": {
57
+ "command": {
58
+ "type": "object",
59
+ "required": ["cwd", "argv"],
60
+ "properties": {
61
+ "cwd": { "type": "string" },
62
+ "argv": {
63
+ "type": "array",
64
+ "minItems": 1,
65
+ "items": { "type": "string" }
66
+ }
67
+ },
68
+ "additionalProperties": false
69
+ },
70
+ "executionContract": {
58
71
  "type": "object",
59
72
  "required": [
60
73
  "version",
61
- "idempotencyKey",
74
+ "capability",
75
+ "mode",
76
+ "executionIdentity",
62
77
  "workbaseRoot",
63
- "target",
64
- "documentRevision",
65
78
  "sourceLocations",
66
- "taskDirectory",
67
- "taskDocument",
68
- "phaseDocument",
69
- "preparedCheckout",
70
- "orchestrator",
71
- "steps",
72
- "successFields"
79
+ "workspace",
80
+ "commands"
73
81
  ],
74
82
  "properties": {
75
83
  "version": { "const": 1 },
76
- "idempotencyKey": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
84
+ "capability": { "const": "agency-execution-v1" },
85
+ "mode": { "enum": ["preview", "applied"] },
86
+ "executionIdentity": {
87
+ "type": "object",
88
+ "required": ["key", "target", "documentRevision"],
89
+ "properties": {
90
+ "key": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
91
+ "target": { "type": "string" },
92
+ "documentRevision": {
93
+ "type": "string",
94
+ "pattern": "^[a-f0-9]{64}$"
95
+ }
96
+ },
97
+ "additionalProperties": false
98
+ },
77
99
  "workbaseRoot": { "type": "string" },
78
- "target": { "type": "string" },
79
- "documentRevision": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
80
- "sourceLocations": { "type": "array", "items": { "type": "string" } },
81
- "taskDirectory": { "type": "string" },
82
- "taskDocument": { "type": "string" },
83
- "phaseDocument": { "type": ["string", "null"] },
84
- "preparedCheckout": { "type": ["string", "null"] },
85
- "orchestrator": {
100
+ "sourceLocations": {
101
+ "type": "array",
102
+ "items": { "type": "string" }
103
+ },
104
+ "workspace": {
86
105
  "type": "object",
87
106
  "required": [
88
- "capability",
89
- "knownCurrentCommandsBypassDiscovery",
90
- "fallback"
107
+ "state",
108
+ "executionDirectory",
109
+ "taskDocument",
110
+ "phaseDocument",
111
+ "checkoutPath"
91
112
  ],
92
113
  "properties": {
93
- "capability": { "const": "agency-kickoff-v1" },
94
- "knownCurrentCommandsBypassDiscovery": { "const": true },
95
- "fallback": { "type": "string" }
114
+ "state": { "enum": ["planned", "materialized"] },
115
+ "executionDirectory": { "type": "string" },
116
+ "taskDocument": { "type": "string" },
117
+ "phaseDocument": { "type": ["string", "null"] },
118
+ "checkoutPath": { "type": ["string", "null"] }
96
119
  },
97
120
  "additionalProperties": false
98
121
  },
99
- "steps": {
122
+ "plannedActions": {
100
123
  "type": "array",
101
- "minItems": 6,
102
124
  "items": {
103
125
  "type": "object",
104
- "required": ["id"],
126
+ "required": ["kind", "target", "checkoutPath"],
105
127
  "properties": {
106
- "id": { "type": "string" },
107
- "argv": { "type": "array", "items": { "type": "string" } },
108
- "cwd": { "type": "string" },
109
- "action": { "type": "string" },
110
- "path": { "type": "string" },
111
- "retry": { "type": "string" },
112
- "recovery": { "type": "string" },
113
- "idempotencyKey": { "type": "string" },
114
- "exactlyOnce": { "type": "boolean" }
128
+ "kind": { "const": "workspace-materialization" },
129
+ "target": { "type": "string" },
130
+ "checkoutPath": { "type": ["string", "null"] }
115
131
  },
116
132
  "additionalProperties": false
117
133
  }
118
134
  },
119
- "successFields": { "type": "array", "items": { "type": "string" } }
135
+ "commands": {
136
+ "type": "object",
137
+ "required": ["work", "context"],
138
+ "properties": {
139
+ "work": { "$ref": "#/$defs/command" },
140
+ "context": { "$ref": "#/$defs/command" }
141
+ },
142
+ "additionalProperties": false
143
+ }
120
144
  },
121
145
  "additionalProperties": false
122
146
  }
@@ -23,7 +23,9 @@ describe("repo command", () => {
23
23
  test("requires a subcommand", async () => {
24
24
  await expect(
25
25
  runTestEffect(repo({ args: [], silent: true })),
26
- ).rejects.toThrow("Available subcommands: setup, add, link, list")
26
+ ).rejects.toThrow(
27
+ "Available subcommands: setup, add, link, materialize, list",
28
+ )
27
29
  })
28
30
 
29
31
  test("requires add arguments", async () => {
@@ -34,6 +36,14 @@ describe("repo command", () => {
34
36
  ).rejects.toThrow("Usage: agency repo add")
35
37
  })
36
38
 
39
+ test("requires a materialize alias", async () => {
40
+ await expect(
41
+ runTestEffect(
42
+ repo({ subcommand: "materialize", args: [], silent: true }),
43
+ ),
44
+ ).rejects.toThrow("Usage: agency repo materialize <alias>")
45
+ })
46
+
37
47
  test("lists repository metadata as JSON", async () => {
38
48
  const logs = await captureLogs(() =>
39
49
  runTestEffect(
@@ -74,6 +74,21 @@ export const repo = (options: RepoOptions) =>
74
74
  return
75
75
  }
76
76
 
77
+ case "materialize": {
78
+ const alias = yield* requireArg(
79
+ options.args,
80
+ 0,
81
+ "Usage: agency repo materialize <alias>",
82
+ )
83
+ const item = yield* repositories.materialize(alias, cwd)
84
+ log(
85
+ options.json
86
+ ? JSON.stringify(item, null, 2)
87
+ : `Materialized repository '${alias}'`,
88
+ )
89
+ return
90
+ }
91
+
77
92
  case "list": {
78
93
  const items = yield* repositories.list(cwd)
79
94
  if (options.json) {
@@ -187,7 +202,7 @@ export const repo = (options: RepoOptions) =>
187
202
  default:
188
203
  return yield* Effect.fail(
189
204
  new Error(
190
- "Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
205
+ "Subcommand is required. Available subcommands: setup, add, link, materialize, list, show, fetch, remove, unlink, rename, remote, verify",
191
206
  ),
192
207
  )
193
208
  }
@@ -200,6 +215,7 @@ Subcommands:
200
215
  setup Plan or apply portable repository setup
201
216
  add <alias> <remote> Create a bare clone
202
217
  link <alias> <path> Link an existing Git repository
218
+ materialize <alias> Replace a linked alias with a managed bare clone
203
219
  list List repository aliases
204
220
  show <alias> Show a repository alias
205
221
  fetch <alias> Fetch and prune a repository
@@ -15,7 +15,7 @@ import { ReviewService } from "../services/ReviewService"
15
15
  import {
16
16
  buildValidationEvidence,
17
17
  normalizeRecalledContext,
18
- } from "../workbase/kickoff-contract"
18
+ } from "../workbase/execution-contract"
19
19
 
20
20
  interface TaskOptions extends BaseCommandOptions {
21
21
  readonly subcommand?: string
@@ -532,12 +532,15 @@ describe("work command", () => {
532
532
  const result = JSON.parse(output!)
533
533
  expect(result.validationEvidence.status).toBe("refreshed")
534
534
  expect(result.validationEvidence.reasons).toEqual(["not-supplied"])
535
- expect(result.kickoff.target).toBe("execution-unit:task/example")
536
- expect(
537
- result.kickoff.steps.filter(
538
- ({ id }: { id: string }) => id === "final-context-verification",
539
- ),
540
- ).toHaveLength(1)
535
+ expect(result.execution).toMatchObject({
536
+ capability: "agency-execution-v1",
537
+ mode: "preview",
538
+ executionIdentity: { target: "execution-unit:task/example" },
539
+ workspace: { state: "planned" },
540
+ commands: {
541
+ work: { argv: ["agency", "work", ".", "--auto"] },
542
+ },
543
+ })
541
544
 
542
545
  const [reusedOutput] = await captureLogs(() =>
543
546
  harness.runPrepare({
@@ -31,11 +31,11 @@ import {
31
31
  } from "../workbase/agent-command"
32
32
  import {
33
33
  assessValidationEvidence,
34
- buildKickoffPlan,
34
+ buildExecutionContract,
35
35
  buildValidationEvidence,
36
36
  normalizeRecalledContext,
37
37
  readValidationEvidence,
38
- } from "../workbase/kickoff-contract"
38
+ } from "../workbase/execution-contract"
39
39
 
40
40
  export interface WorkOptions extends BaseCommandOptions {
41
41
  readonly directory?: string
@@ -556,22 +556,21 @@ export const workPrepare = (options: WorkOptions = {}) =>
556
556
  workspace,
557
557
  validation,
558
558
  validationEvidence: { ...assessment.disposition, evidence },
559
- kickoff: buildKickoffPlan({
559
+ execution: buildExecutionContract({
560
560
  workbaseRoot: root,
561
561
  target,
562
- taskId,
563
- phaseId: phase?.id,
564
562
  taskPath: task.path,
565
563
  phasePath: phase?.path,
566
564
  checkoutPath: workspace.writablePath ?? workspace.reviewPath,
567
565
  documentRevision: document.revision,
566
+ dryRun: options.dryRun === true,
568
567
  }),
569
568
  }
570
569
  if (options.json) {
571
570
  log(JSON.stringify(result, null, 2))
572
571
  } else {
573
572
  log(
574
- `${workspace.dryRun ? "Kickoff plan" : "Workspace ready"}: ${workspace.writablePath ?? workspace.reviewPath}`,
573
+ `${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath ?? workspace.reviewPath}`,
575
574
  )
576
575
  }
577
576
  })
@@ -156,19 +156,46 @@ describe("IntegrationService", () => {
156
156
  ])
157
157
  })
158
158
 
159
- test("generates the canonical Agency kickoff recipe with precedence", () => {
160
- expect(managedWorkbaseAgents).toContain(
161
- "takes precedence over generic Herdr defaults",
159
+ test("generates the complete Agency command fast paths with precedence", () => {
160
+ const body = managedBody(managedWorkbaseAgents)
161
+
162
+ expect(body.indexOf("## Command Fast Paths")).toBeLessThan(
163
+ body.indexOf("## Bootstrap"),
162
164
  )
163
165
  expect(managedWorkbaseAgents).toContain(
166
+ "take precedence over separately installed Agency skill guidance",
167
+ )
168
+ for (const recipe of [
169
+ "Create a single-phase task only",
164
170
  "agency work prepare <slug> --evidence",
171
+ "Materialize an existing execution unit without starting it",
172
+ "agency sync <task> [phase] --json",
173
+ "agency phase create <task> <new-phase> --first-phase <existing-phase>",
174
+ "agency archive task <task> --dry-run --json",
175
+ "agency task create <slug> --review <alias>",
176
+ "agency status --json",
177
+ "agency task status <task> dropped --if-revision <revision> --json",
178
+ "Continue already materialized work",
179
+ "agency pr create <task> [phase]",
180
+ "agency finish <task> [phase] --session-id <id>",
181
+ "agency task create <slug> --multi-phase",
182
+ "agency task handoff <investigation-task> <new-task>",
183
+ "agency review refresh <task> --if-revision <revision> --json",
184
+ ]) {
185
+ expect(managedWorkbaseAgents).toContain(recipe)
186
+ }
187
+ expect(managedWorkbaseAgents).toContain("agency push --json")
188
+ expect(managedWorkbaseAgents).toContain("agency-execution-v1")
189
+ expect(managedWorkbaseAgents).toContain(
190
+ "Never pass `--work` or `--auto` to `agency task create`",
165
191
  )
166
- expect(managedWorkbaseAgents).toContain("agency-kickoff-v1")
167
192
  expect(managedWorkbaseAgents).toContain(
168
- "call Herdr help, skill, or CLI discovery",
193
+ "Use `agency <command> --help` only as a recovery",
194
+ )
195
+ expect(managedWorkbaseAgents).not.toContain("`agency --help`")
196
+ expect(managedWorkbaseAgents).not.toContain(
197
+ "`agency worktree prepare <task>",
169
198
  )
170
- expect(managedWorkbaseAgents).toContain("exactly one final")
171
- expect(managedWorkbaseAgents).toContain("leave the agent in the")
172
199
  })
173
200
 
174
201
  test("generates a dynamic workbase plugin", () => {
@@ -597,8 +624,10 @@ describe("IntegrationService", () => {
597
624
  expect(body).toContain("Agency worker launch target: <target>.")
598
625
  expect(body).toContain("environment variables and a generated")
599
626
  expect(body).toContain("the initial instruction is a generated")
600
- expect(body).toContain("Herdr state is never part of worker identity")
601
- expect(body).toMatch(/If the prompt\s+and context disagree/)
627
+ expect(body).toContain(
628
+ "External session state is never part of worker identity",
629
+ )
630
+ expect(body).toMatch(/If\s+the prompt\s+and context disagree/)
602
631
  expect(body).toContain("marks execution work")
603
632
  expect(body).toContain("without creating a claim")
604
633
  expect(body).toContain("formatting, type checks, build, dead-code checks")
@@ -657,7 +686,9 @@ describe("IntegrationService", () => {
657
686
  description:
658
687
  "Handles Agency workbase orchestration and workflow operations with the Agency CLI",
659
688
  mode: "subagent",
660
- prompt: expect.stringContaining("agency context . --json"),
689
+ prompt: expect.stringMatching(
690
+ /agency context \. --json[\s\S]+agency work prepare[\s\S]+never pass `--work` or `--auto`/,
691
+ ),
661
692
  },
662
693
  plan: {
663
694
  disable: true,
@@ -689,10 +720,7 @@ describe("IntegrationService", () => {
689
720
  expect(config.agent.agency.hidden).toBeUndefined()
690
721
  expect(config.agent.agency.steps).toBeUndefined()
691
722
  expect(config.agent.agency.prompt).toContain(
692
- "verify that the agent started successfully",
693
- )
694
- expect(config.agent.agency.prompt).toContain(
695
- "return without waiting for the task to finish",
723
+ "Return the prepared execution contract to the caller",
696
724
  )
697
725
  expect(config.agent["agency-plan"].prompt).toContain(
698
726
  "Start with `agency context . --json`",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, stat } from "node:fs/promises"
3
+ import { mkdir, realpath, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -16,6 +16,18 @@ const runGit = async (args: string[]) => {
16
16
  }
17
17
  }
18
18
 
19
+ const gitOutput = async (args: string[]) => {
20
+ const process = Bun.spawn(["git", ...args], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ })
24
+ await process.exited
25
+ if (process.exitCode !== 0) {
26
+ throw new Error(await new Response(process.stderr).text())
27
+ }
28
+ return (await new Response(process.stdout).text()).trim()
29
+ }
30
+
19
31
  const portableRemote = (name: string) =>
20
32
  `https://example.com/agency-tests/${name}.git`
21
33
 
@@ -159,6 +171,143 @@ describe("RepositoryService", () => {
159
171
  })
160
172
  })
161
173
 
174
+ test("materializes a linked alias without invalidating active worktrees", async () => {
175
+ const target = join(root, "linked-repository")
176
+ const checkout = join(root, "tasks/active/code/linked")
177
+ await mkdir(target, { recursive: true })
178
+ await runGit(["init", "--initial-branch=main", target])
179
+ await runGit(["-C", target, "config", "user.email", "test@example.com"])
180
+ await runGit(["-C", target, "config", "user.name", "Test"])
181
+ await Bun.write(join(target, "README.md"), "linked\n")
182
+ await runGit(["-C", target, "add", "README.md"])
183
+ await runGit(["-C", target, "commit", "-m", "initial"])
184
+ await setPortableOrigin(target, "materialized")
185
+ await runTestEffect(
186
+ RepositoryService.pipe(
187
+ Effect.flatMap((service) => service.link("linked", target, root)),
188
+ ),
189
+ )
190
+ await write(
191
+ root,
192
+ "tasks/active/TASK.md",
193
+ `---
194
+ ticketUrl: null
195
+ repo: linked
196
+ branch: task/active
197
+ base: main
198
+ pr: null
199
+ status: working
200
+ ---
201
+ `,
202
+ )
203
+ await mkdir(dirname(checkout), { recursive: true })
204
+ await runGit([
205
+ "-C",
206
+ target,
207
+ "worktree",
208
+ "add",
209
+ "-b",
210
+ "task/active",
211
+ checkout,
212
+ "main",
213
+ ])
214
+ await Bun.write(join(checkout, "dirty.txt"), "preserved\n")
215
+
216
+ const result = await runTestEffect(
217
+ RepositoryService.pipe(
218
+ Effect.flatMap((service) => service.materialize("linked", root)),
219
+ ),
220
+ )
221
+
222
+ expect(result.kind).toBe("bare")
223
+ expect(result.target).toBeNull()
224
+ expect(result.states).toEqual(["declared", "materialized"])
225
+ expect(await Bun.file(join(checkout, "dirty.txt")).text()).toBe(
226
+ "preserved\n",
227
+ )
228
+ expect(await gitOutput(["-C", checkout, "branch", "--show-current"])).toBe(
229
+ "task/active",
230
+ )
231
+ expect(
232
+ await gitOutput(["-C", checkout, "rev-parse", "--git-common-dir"]),
233
+ ).toBe(await realpath(join(root, "repos/linked")))
234
+ expect(
235
+ await gitOutput(["-C", target, "worktree", "list", "--porcelain"]),
236
+ ).not.toContain(checkout)
237
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
238
+ expect(await Bun.file(join(root, "tasks/active/TASK.md")).text()).toContain(
239
+ "repo: linked",
240
+ )
241
+ })
242
+
243
+ test("materializes an alias linked through an existing worktree", async () => {
244
+ const primary = join(root, "primary-repository")
245
+ const linkedWorktree = join(root, "linked-worktree")
246
+ await mkdir(primary, { recursive: true })
247
+ await runGit(["init", "--initial-branch=main", primary])
248
+ await runGit(["-C", primary, "config", "user.email", "test@example.com"])
249
+ await runGit(["-C", primary, "config", "user.name", "Test"])
250
+ await Bun.write(join(primary, "README.md"), "linked worktree\n")
251
+ await runGit(["-C", primary, "add", "README.md"])
252
+ await runGit(["-C", primary, "commit", "-m", "initial"])
253
+ await setPortableOrigin(primary, "linked-worktree")
254
+ await runGit([
255
+ "-C",
256
+ primary,
257
+ "worktree",
258
+ "add",
259
+ "-b",
260
+ "linked-branch",
261
+ linkedWorktree,
262
+ "main",
263
+ ])
264
+ await runTestEffect(
265
+ RepositoryService.pipe(
266
+ Effect.flatMap((service) =>
267
+ service.link("linked", linkedWorktree, root),
268
+ ),
269
+ ),
270
+ )
271
+
272
+ await runTestEffect(
273
+ RepositoryService.pipe(
274
+ Effect.flatMap((service) => service.materialize("linked", root)),
275
+ ),
276
+ )
277
+
278
+ expect(
279
+ await gitOutput(["-C", linkedWorktree, "rev-parse", "--git-common-dir"]),
280
+ ).toBe(await realpath(join(root, "repos/linked")))
281
+ expect(
282
+ await gitOutput(["-C", primary, "worktree", "list", "--porcelain"]),
283
+ ).not.toContain(linkedWorktree)
284
+ })
285
+
286
+ test("refuses to materialize linked aliases in jj workbases", async () => {
287
+ if (!Bun.which("jj")) return
288
+ await Bun.write(
289
+ join(root, "agency.json"),
290
+ JSON.stringify({ version: 2, vcs: "jj" }),
291
+ )
292
+ const target = join(root, "linked-jj-repository")
293
+ await mkdir(target, { recursive: true })
294
+ await runGit(["init", "--initial-branch=main", target])
295
+ await setPortableOrigin(target, "linked-jj")
296
+ await runTestEffect(
297
+ RepositoryService.pipe(
298
+ Effect.flatMap((service) => service.link("linked", target, root)),
299
+ ),
300
+ )
301
+
302
+ await expect(
303
+ runTestEffect(
304
+ RepositoryService.pipe(
305
+ Effect.flatMap((service) => service.materialize("linked", root)),
306
+ ),
307
+ ),
308
+ ).rejects.toThrow("only supported for Git workbases")
309
+ })
310
+
162
311
  test("rejects invalid and duplicate aliases", async () => {
163
312
  const source = join(root, "source.git")
164
313
  await runGit(["init", "--bare", "--initial-branch=main", source])