@markjaquith/agency 3.2.1 → 3.2.3

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 (54) hide show
  1. package/README.md +30 -60
  2. package/cli-main.ts +38 -72
  3. package/fixtures/protocol/orchestration-recipes.json +9 -34
  4. package/package.json +1 -4
  5. package/schemas/agency-graph-v1.schema.json +2 -31
  6. package/src/cli-parser.test.ts +13 -132
  7. package/src/cli-parser.ts +10 -101
  8. package/src/cli.test.ts +12 -111
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/push.test.ts +4 -2
  11. package/src/commands/push.ts +2 -0
  12. package/src/commands/sync.ts +3 -3
  13. package/src/commands/validate.ts +3 -1
  14. package/src/commands/work.test.ts +70 -8
  15. package/src/commands/work.ts +15 -8
  16. package/src/graph-schema.ts +0 -2
  17. package/src/protocol.test.ts +24 -25
  18. package/src/protocol.ts +56 -15
  19. package/src/readiness.test.ts +2 -2
  20. package/src/services/ArchiveBulkService.test.ts +0 -88
  21. package/src/services/ArchiveService.ts +0 -32
  22. package/src/services/FileSystemService.ts +2 -0
  23. package/src/services/GraphMutationService.ts +0 -26
  24. package/src/services/GraphService.test.ts +1 -1
  25. package/src/services/IntegrationService.test.ts +4 -4
  26. package/src/services/LifecycleTransaction.ts +5 -1
  27. package/src/services/PhaseService.ts +1 -8
  28. package/src/services/PushService.test.ts +110 -4
  29. package/src/services/PushService.ts +435 -85
  30. package/src/services/ReadinessService.test.ts +0 -34
  31. package/src/services/ReadinessService.ts +1 -20
  32. package/src/services/ReviewService.test.ts +0 -64
  33. package/src/services/ReviewService.ts +0 -5
  34. package/src/services/SyncService.test.ts +75 -88
  35. package/src/services/SyncService.ts +52 -123
  36. package/src/services/TaskPhaseService.test.ts +13 -1
  37. package/src/services/TaskService.ts +1 -7
  38. package/src/services/WorkbaseService.ts +0 -3
  39. package/src/services/WorktreeService.test.ts +192 -1
  40. package/src/services/WorktreeService.ts +169 -34
  41. package/src/test-utils.ts +0 -2
  42. package/src/usage-log.test.ts +11 -1
  43. package/src/usage-log.ts +22 -4
  44. package/src/utils/process.test.ts +10 -0
  45. package/src/utils/process.ts +59 -6
  46. package/src/workbase/AGENTS.md +9 -15
  47. package/src/workbase/agent-command.test.ts +0 -3
  48. package/src/workbase/agent-command.ts +0 -6
  49. package/src/workbase/document-revision.ts +0 -4
  50. package/src/workbase/schemas.test.ts +12 -25
  51. package/src/workbase/schemas.ts +0 -16
  52. package/src/commands/claim.ts +0 -122
  53. package/src/services/ClaimService.test.ts +0 -415
  54. package/src/services/ClaimService.ts +0 -608
@@ -182,6 +182,14 @@ const formatCommand = (args: readonly string[]) =>
182
182
  )
183
183
  .join(" ")
184
184
 
185
+ const describeError = (error: unknown) =>
186
+ typeof error === "object" &&
187
+ error !== null &&
188
+ "message" in error &&
189
+ typeof error.message === "string"
190
+ ? error.message
191
+ : String(error)
192
+
185
193
  const runPostCheckoutHook = (options: {
186
194
  readonly command: readonly string[] | undefined
187
195
  readonly variables: CheckoutCommandVariables
@@ -1871,6 +1879,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1871
1879
  }
1872
1880
  | { review: { repo: string; commit: string } }
1873
1881
  let codePath: string
1882
+ let executionState: {
1883
+ readonly status: string
1884
+ readonly claim?: { readonly state: string }
1885
+ }
1874
1886
  if ("phases" in task.data) {
1875
1887
  if (!phaseId) {
1876
1888
  return yield* new WorktreeError({
@@ -1880,6 +1892,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1880
1892
  const phase =
1881
1893
  options.phase ?? (yield* phases.show(taskId, phaseId, root))
1882
1894
  execution = phase.data
1895
+ executionState = phase.data
1883
1896
  codePath = join(dirname(phase.path), "code")
1884
1897
  } else {
1885
1898
  if (phaseId) {
@@ -1888,8 +1901,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1888
1901
  })
1889
1902
  }
1890
1903
  execution = task.data
1904
+ executionState = task.data
1891
1905
  codePath = join(dirname(task.path), "code")
1892
1906
  }
1907
+ const ownerLabel = phaseId
1908
+ ? `Phase '${taskId}/${phaseId}'`
1909
+ : `Task '${taskId}'`
1910
+ if (executionState.claim?.state === "active") {
1911
+ return yield* new WorktreeError({
1912
+ message: `${ownerLabel} has an active claim; release or finish it before removing its worktrees`,
1913
+ })
1914
+ }
1915
+ if (
1916
+ executionState.status === "working" ||
1917
+ executionState.status === "delegated"
1918
+ ) {
1919
+ return yield* new WorktreeError({
1920
+ message: `${ownerLabel} has active '${executionState.status}' ownership; reopen it before removing its worktrees`,
1921
+ })
1922
+ }
1893
1923
 
1894
1924
  const codeDirectoryExists = yield* fs.isDirectory(codePath)
1895
1925
  const removalPlans: {
@@ -1931,6 +1961,11 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1931
1961
  const alias = checkout.repo
1932
1962
  const repositoryPath = join(root, "repos", alias)
1933
1963
  const checkoutPath = join(codePath, alias)
1964
+ if ((yield* fs.readSymlinkTarget(checkoutPath)) !== null) {
1965
+ return yield* new WorktreeError({
1966
+ message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is a symbolic link`,
1967
+ })
1968
+ }
1934
1969
  if (
1935
1970
  (yield* fs.exists(checkoutPath)) &&
1936
1971
  !(yield* fs.isDirectory(checkoutPath))
@@ -2024,7 +2059,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2024
2059
  ["git", "-C", checkoutPath, "status", "--porcelain"],
2025
2060
  { captureOutput: true },
2026
2061
  )
2027
- if (status.exitCode !== 0 || status.stdout.trim()) {
2062
+ if (status.exitCode !== 0) {
2063
+ return yield* new WorktreeError({
2064
+ message: `Failed to remove worktree for '${alias}': checkout cleanliness could not be verified: ${status.stderr.trim() || `git status exited with code ${status.exitCode}`}`,
2065
+ })
2066
+ }
2067
+ if (status.stdout.trim()) {
2028
2068
  return yield* new WorktreeError({
2029
2069
  message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
2030
2070
  })
@@ -2039,6 +2079,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2039
2079
  head: registered.head,
2040
2080
  branch: registered.branch?.replace(/^refs\/heads\//, ""),
2041
2081
  })
2082
+ if (!checkoutExists) {
2083
+ const unrelatedStale: string[] = []
2084
+ for (const worktree of parseWorktreeList(listed.stdout)) {
2085
+ const worktreePath = (yield* fs.exists(worktree.path))
2086
+ ? yield* fs.realPath(worktree.path)
2087
+ : resolve(worktree.path)
2088
+ if (
2089
+ worktreePath !== registered.path &&
2090
+ !(yield* fs.exists(worktree.path))
2091
+ ) {
2092
+ unrelatedStale.push(worktree.path)
2093
+ }
2094
+ }
2095
+ if (unrelatedStale.length > 0) {
2096
+ return yield* new WorktreeError({
2097
+ message: `Cannot remove stale registration ${registered.path} because pruning would also remove unrelated stale registrations: ${unrelatedStale.join(", ")}`,
2098
+ })
2099
+ }
2100
+ }
2042
2101
  }
2043
2102
  for (const plan of removalPlans) {
2044
2103
  if (!plan.checkoutExists || !plan.head) continue
@@ -2056,6 +2115,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2056
2115
  }
2057
2116
 
2058
2117
  const completed: typeof removalPlans = []
2118
+ const planState = (plan: (typeof removalPlans)[number]) =>
2119
+ Effect.gen(function* () {
2120
+ const checkoutExists = yield* fs.isDirectory(plan.checkoutPath)
2121
+ const listed = yield* fs.runCommand(
2122
+ [
2123
+ "git",
2124
+ "-C",
2125
+ plan.repositoryPath,
2126
+ "worktree",
2127
+ "list",
2128
+ "--porcelain",
2129
+ "-z",
2130
+ ],
2131
+ { captureOutput: true },
2132
+ )
2133
+ if (listed.exitCode !== 0) {
2134
+ return yield* new WorktreeError({
2135
+ message: `Failed to inspect worktrees for '${plan.alias}' during recovery: ${listed.stderr.trim() || `git worktree list exited with code ${listed.exitCode}`}`,
2136
+ })
2137
+ }
2138
+ let registered = false
2139
+ for (const worktree of parseWorktreeList(listed.stdout)) {
2140
+ const worktreePath = (yield* fs.exists(worktree.path))
2141
+ ? yield* fs.realPath(worktree.path)
2142
+ : resolve(worktree.path)
2143
+ if (worktreePath === plan.registeredPath) {
2144
+ registered = true
2145
+ break
2146
+ }
2147
+ }
2148
+ return { checkoutExists, registered }
2149
+ })
2059
2150
  const removed = yield* Effect.gen(function* () {
2060
2151
  for (const plan of removalPlans) {
2061
2152
  const command = plan.checkoutExists
@@ -2080,8 +2171,18 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2080
2171
  captureOutput: true,
2081
2172
  })
2082
2173
  if (result.exitCode !== 0) {
2174
+ const state = yield* planState(plan).pipe(
2175
+ Effect.catchAll(() => Effect.succeed(undefined)),
2176
+ )
2177
+ if (
2178
+ !state ||
2179
+ state.checkoutExists !== plan.checkoutExists ||
2180
+ !state.registered
2181
+ ) {
2182
+ completed.push(plan)
2183
+ }
2083
2184
  return yield* new WorktreeError({
2084
- message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
2185
+ message: `Failed to remove worktree for '${plan.alias}': ${result.stderr.trim() || `git exited with code ${result.exitCode}`}`,
2085
2186
  })
2086
2187
  }
2087
2188
  completed.push(plan)
@@ -2099,43 +2200,77 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2099
2200
  for (const plan of [...completed].reverse()) {
2100
2201
  if (!plan.checkoutExists) {
2101
2202
  manualRecovery.push(
2102
- `Re-run worktree repair for stale registration ${plan.registeredPath}`,
2203
+ `Restore stale registration ${plan.registeredPath} if it is still needed`,
2103
2204
  )
2104
2205
  continue
2105
2206
  }
2106
- yield* fs.createDirectory(dirname(plan.checkoutPath))
2107
- const command = plan.branch
2108
- ? [
2109
- "git",
2110
- "-C",
2111
- plan.repositoryPath,
2112
- "worktree",
2113
- "add",
2114
- plan.checkoutPath,
2115
- plan.branch,
2116
- ]
2117
- : [
2118
- "git",
2119
- "-C",
2120
- plan.repositoryPath,
2121
- "worktree",
2122
- "add",
2123
- "--detach",
2124
- plan.checkoutPath,
2125
- plan.head!,
2126
- ]
2127
- const restored = yield* fs.runCommand(command, {
2128
- captureOutput: true,
2129
- })
2130
- if (restored.exitCode === 0)
2131
- rolledBack.push(plan.checkoutPath)
2132
- else manualRecovery.push(`Restore ${plan.checkoutPath}`)
2207
+ const recovery = Effect.gen(function* () {
2208
+ const state = yield* planState(plan)
2209
+ if (state.checkoutExists && state.registered) {
2210
+ rolledBack.push(plan.checkoutPath)
2211
+ return
2212
+ }
2213
+ if (state.checkoutExists || state.registered) {
2214
+ manualRecovery.push(
2215
+ `Restore ${plan.checkoutPath}; checkout path ${state.checkoutExists ? "exists" : "is missing"} and registration ${state.registered ? "exists" : "is missing"}`,
2216
+ )
2217
+ return
2218
+ }
2219
+ yield* fs.createDirectory(dirname(plan.checkoutPath))
2220
+ const command = plan.branch
2221
+ ? [
2222
+ "git",
2223
+ "-C",
2224
+ plan.repositoryPath,
2225
+ "worktree",
2226
+ "add",
2227
+ plan.checkoutPath,
2228
+ plan.branch,
2229
+ ]
2230
+ : [
2231
+ "git",
2232
+ "-C",
2233
+ plan.repositoryPath,
2234
+ "worktree",
2235
+ "add",
2236
+ "--detach",
2237
+ plan.checkoutPath,
2238
+ plan.head!,
2239
+ ]
2240
+ const restored = yield* fs.runCommand(command, {
2241
+ captureOutput: true,
2242
+ })
2243
+ if (restored.exitCode === 0) {
2244
+ rolledBack.push(plan.checkoutPath)
2245
+ } else {
2246
+ manualRecovery.push(
2247
+ `Restore ${plan.checkoutPath}: ${restored.stderr.trim() || `git exited with code ${restored.exitCode}`}`,
2248
+ )
2249
+ }
2250
+ }).pipe(
2251
+ Effect.catchAll((recoveryCause) =>
2252
+ Effect.sync(() => {
2253
+ manualRecovery.push(
2254
+ `Restore ${plan.checkoutPath}: ${describeError(recoveryCause)}`,
2255
+ )
2256
+ }),
2257
+ ),
2258
+ )
2259
+ yield* recovery
2133
2260
  }
2261
+ const causeMessage = describeError(cause)
2134
2262
  return yield* new WorktreeError({
2135
- message: manualRecovery.length
2136
- ? "Worktree removal failed and requires manual recovery"
2137
- : "Worktree removal failed; removed worktrees were restored",
2138
- completed: completed.map((plan) => plan.checkoutPath),
2263
+ message:
2264
+ completed.length === 0
2265
+ ? `${causeMessage}; no worktrees were removed`
2266
+ : manualRecovery.length
2267
+ ? `${causeMessage}. Worktree removal requires manual recovery`
2268
+ : `${causeMessage}. Removed worktrees were restored`,
2269
+ completed: completed.map((plan) =>
2270
+ plan.checkoutExists
2271
+ ? plan.checkoutPath
2272
+ : plan.registeredPath,
2273
+ ),
2139
2274
  rolledBack,
2140
2275
  manualRecovery,
2141
2276
  cause,
package/src/test-utils.ts CHANGED
@@ -16,7 +16,6 @@ import { ArchiveService } from "./services/ArchiveService"
16
16
  import { IntegrationService } from "./services/IntegrationService"
17
17
  import { ContextService } from "./services/ContextService"
18
18
  import { GraphService } from "./services/GraphService"
19
- import { ClaimService } from "./services/ClaimService"
20
19
  import { SyncService } from "./services/SyncService"
21
20
  import { ReadinessService } from "./services/ReadinessService"
22
21
  import { GraphMutationService } from "./services/GraphMutationService"
@@ -48,7 +47,6 @@ const TestLayer = Layer.mergeAll(
48
47
  IntegrationService.Default,
49
48
  ContextService.Default,
50
49
  GraphService.Default,
51
- ClaimService.Default,
52
50
  SyncService.Default,
53
51
  ReadinessService.Default,
54
52
  GraphMutationService.Default,
@@ -24,6 +24,13 @@ describe("usage logging", () => {
24
24
  durationMs: 12.4,
25
25
  outcome: "success",
26
26
  exitStatus: 0,
27
+ ...(commandPath === "context"
28
+ ? {
29
+ vcs: "git" as const,
30
+ terminalStage: "publish",
31
+ category: "success",
32
+ }
33
+ : {}),
27
34
  },
28
35
  "1.2.3",
29
36
  env,
@@ -35,7 +42,7 @@ describe("usage logging", () => {
35
42
  )
36
43
  expect(await exportUsageEvents(env)).toEqual([
37
44
  expect.objectContaining({
38
- version: 1,
45
+ version: 2,
39
46
  sessionId: "session-1",
40
47
  sessionSequence: 1,
41
48
  agencyVersion: "1.2.3",
@@ -48,6 +55,9 @@ describe("usage logging", () => {
48
55
  expect.objectContaining({
49
56
  sessionSequence: 2,
50
57
  commandPath: "context",
58
+ vcs: "git",
59
+ terminalStage: "publish",
60
+ category: "success",
51
61
  }),
52
62
  ])
53
63
  })
package/src/usage-log.ts CHANGED
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
2
2
  import { mkdir } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
4
 
5
- const USAGE_EVENT_VERSION = 1 as const
5
+ const USAGE_EVENT_VERSION = 2 as const
6
6
  const DEFAULT_RETENTION_DAYS = 90
7
7
 
8
8
  export interface UsageEvent {
@@ -11,6 +11,9 @@ export interface UsageEvent {
11
11
  readonly durationMs: number
12
12
  readonly exitStatus: number
13
13
  readonly outcome: "success" | "failure"
14
+ readonly vcs?: "git"
15
+ readonly terminalStage?: string
16
+ readonly category?: string
14
17
  }
15
18
 
16
19
  const stateDirectory = (env: NodeJS.ProcessEnv) =>
@@ -50,6 +53,13 @@ const openDatabase = async (env: NodeJS.ProcessEnv) => {
50
53
  exit_status INTEGER NOT NULL
51
54
  )
52
55
  `)
56
+ for (const column of ["vcs", "terminal_stage", "category"]) {
57
+ try {
58
+ database.run(`ALTER TABLE usage_events ADD COLUMN ${column} TEXT`)
59
+ } catch {
60
+ // Existing databases already have migrated columns.
61
+ }
62
+ }
53
63
  database.run(
54
64
  "CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
55
65
  )
@@ -74,11 +84,11 @@ export async function recordUsageEvent(
74
84
  INSERT INTO usage_events (
75
85
  event_version, session_id, session_sequence, occurred_at,
76
86
  agency_version, command_path, flag_names, duration_ms,
77
- outcome, exit_status
87
+ outcome, exit_status, vcs, terminal_stage, category
78
88
  ) VALUES (
79
89
  ?, ?,
80
90
  (SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
81
- ?, ?, ?, ?, ?, ?, ?
91
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
82
92
  )
83
93
  `)
84
94
  .run(
@@ -92,6 +102,9 @@ export async function recordUsageEvent(
92
102
  Math.max(0, Math.round(event.durationMs)),
93
103
  event.outcome,
94
104
  event.exitStatus,
105
+ event.vcs ?? null,
106
+ event.terminalStage ?? null,
107
+ event.category ?? null,
95
108
  )
96
109
  if (Math.random() < 0.01) {
97
110
  const days = retentionDays(env)
@@ -119,7 +132,7 @@ export async function exportUsageEvents(
119
132
  .query(`
120
133
  SELECT event_version, session_id, session_sequence, occurred_at,
121
134
  agency_version, command_path, flag_names, duration_ms,
122
- outcome, exit_status
135
+ outcome, exit_status, vcs, terminal_stage, category
123
136
  FROM usage_events ORDER BY occurred_at, id
124
137
  `)
125
138
  .all() as Record<string, string | number>[]
@@ -134,6 +147,11 @@ export async function exportUsageEvents(
134
147
  durationMs: row.duration_ms,
135
148
  outcome: row.outcome,
136
149
  exitStatus: row.exit_status,
150
+ ...(row.vcs == null ? {} : { vcs: row.vcs }),
151
+ ...(row.terminal_stage == null
152
+ ? {}
153
+ : { terminalStage: row.terminal_stage }),
154
+ ...(row.category == null ? {} : { category: row.category }),
137
155
  }))
138
156
  } catch {
139
157
  return []
@@ -64,4 +64,14 @@ describe("spawnProcess", () => {
64
64
  expect(result.stderr).toContain("err:0:")
65
65
  expect(result.stderr).toContain(`err:${lineCount - 1}:`)
66
66
  })
67
+
68
+ test("terminates timed-out process groups", async () => {
69
+ const startedAt = performance.now()
70
+ await expect(
71
+ Effect.runPromise(
72
+ spawnProcess(["sh", "-c", "sleep 30 & wait"], { timeoutMs: 25 }),
73
+ ),
74
+ ).rejects.toThrow("Process timed out")
75
+ expect(performance.now() - startedAt).toBeLessThan(1_000)
76
+ })
67
77
  })
@@ -18,6 +18,7 @@ interface SpawnOptions {
18
18
  readonly stdout?: "pipe" | "inherit" | "tee"
19
19
  readonly stderr?: "pipe" | "inherit" | "tee"
20
20
  readonly env?: Record<string, string>
21
+ readonly timeoutMs?: number
21
22
  }
22
23
 
23
24
  const readOutput = async (
@@ -45,8 +46,14 @@ class ProcessError extends Data.TaggedError("ProcessError")<{
45
46
  command: string
46
47
  exitCode: number
47
48
  stderr: string
49
+ timedOut?: boolean
50
+ timeoutMs?: number
51
+ elapsedMs?: number
48
52
  }> {
49
53
  override get message(): string {
54
+ if (this.timedOut) {
55
+ return `Process timed out after ${this.elapsedMs ?? this.timeoutMs} ms: ${this.command}${this.stderr ? `\n${this.stderr}` : ""}`
56
+ }
50
57
  return (
51
58
  this.stderr ||
52
59
  `Process failed with exit code ${this.exitCode}: ${this.command}`
@@ -65,12 +72,14 @@ export const spawnProcess = (
65
72
  ): Effect.Effect<ProcessResult, ProcessError> =>
66
73
  Effect.tryPromise({
67
74
  try: async () => {
75
+ const startedAt = performance.now()
68
76
  const proc = Bun.spawn([...args], {
69
77
  cwd: options?.cwd ?? process.cwd(),
70
78
  stdin: options?.stdin ?? "pipe",
71
79
  stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
72
80
  stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
73
81
  env: options?.env ? { ...process.env, ...options.env } : process.env,
82
+ detached: options?.timeoutMs !== undefined,
74
83
  })
75
84
  // Start draining stdout/stderr immediately so verbose subprocesses
76
85
  // cannot block on filled pipe buffers before they exit.
@@ -89,11 +98,53 @@ export const spawnProcess = (
89
98
  options?.stderr === "tee" ? process.stderr : undefined,
90
99
  )
91
100
 
101
+ let timedOut = false
102
+ let timer: ReturnType<typeof setTimeout> | undefined
103
+ const exited =
104
+ options?.timeoutMs === undefined
105
+ ? proc.exited
106
+ : Promise.race([
107
+ proc.exited,
108
+ new Promise<number>((resolve) => {
109
+ timer = setTimeout(async () => {
110
+ timedOut = true
111
+ try {
112
+ process.kill(-proc.pid, "SIGTERM")
113
+ } catch {
114
+ proc.kill("SIGTERM")
115
+ }
116
+ const stopped = await Promise.race([
117
+ proc.exited.then(() => true),
118
+ Bun.sleep(250).then(() => false),
119
+ ])
120
+ if (!stopped) {
121
+ try {
122
+ process.kill(-proc.pid, "SIGKILL")
123
+ } catch {
124
+ proc.kill("SIGKILL")
125
+ }
126
+ }
127
+ resolve(await proc.exited)
128
+ }, options.timeoutMs)
129
+ }),
130
+ ])
92
131
  const [exitCode, stdout, stderr] = await Promise.all([
93
- proc.exited,
132
+ exited,
94
133
  stdoutPromise,
95
134
  stderrPromise,
96
135
  ])
136
+ if (timer) clearTimeout(timer)
137
+ if (timedOut) {
138
+ throw new ProcessError({
139
+ command: args.join(" "),
140
+ exitCode:
141
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
142
+ stderr: stderr.trim(),
143
+ timedOut: true,
144
+ timeoutMs: options?.timeoutMs,
145
+ elapsedMs: Math.round(performance.now() - startedAt),
146
+ })
147
+ }
97
148
 
98
149
  return {
99
150
  stdout: stdout.trim(),
@@ -103,9 +154,11 @@ export const spawnProcess = (
103
154
  }
104
155
  },
105
156
  catch: (error) =>
106
- new ProcessError({
107
- command: args.join(" "),
108
- exitCode: -1,
109
- stderr: error instanceof Error ? error.message : String(error),
110
- }),
157
+ error instanceof ProcessError
158
+ ? error
159
+ : new ProcessError({
160
+ command: args.join(" "),
161
+ exitCode: -1,
162
+ stderr: error instanceof Error ? error.message : String(error),
163
+ }),
111
164
  })
@@ -46,9 +46,7 @@ retain `--if-revision` guards when shown, and do not add flags that are not show
46
46
  `agency push --json`. Create and record a pull request with
47
47
  `agency pr create <task> [phase] [--draft] [--title <title>] [--label <label>] --json`;
48
48
  do not run a separate push first because `pr create` owns publication.
49
- 12. Complete genuine non-PR work. For an active claim, run
50
- `agency finish <task> [phase] --session-id <id> --revision <revision> --outcome done --no-pull-request --summary <text> [--evidence-url <url>]`.
51
- Without a claim, run
49
+ 12. Complete genuine non-PR work. Run
52
50
  `agency task status <task> done --if-revision <revision> --no-pull-request --summary <text> [--evidence-url <url>] --json`
53
51
  or
54
52
  `agency phase status <task> <phase> done --if-revision <revision> --no-pull-request --summary <text> [--evidence-url <url>] --json`.
@@ -162,14 +160,14 @@ revision stale, and Agency must not silently rewrite that evidence.
162
160
 
163
161
  ## Safety
164
162
 
165
- - Stop on validation errors, dependency blockers, an unexpected writable
166
- repository, or a conflicting active claim.
163
+ - Stop on validation errors, dependency blockers, or an unexpected writable
164
+ repository.
167
165
  - Do not manually create, move, or remove worktrees under `code/`.
168
166
  - Use `agency archive`, rather than moving work item folders manually.
169
167
  - Do not edit bare repositories or repository symlinks under `repos/`.
170
168
  - Never invent entity IDs, revisions, PR state, dependency completion, or
171
169
  checkout state. Preserve parent backlinks and dependency declarations.
172
- - Do not bypass dirty-worktree, active-claim, revision, or readiness protections.
170
+ - Do not bypass dirty-worktree, revision, or readiness protections.
173
171
  - Do not run `agency work` from an active agent session unless the user
174
172
  explicitly asks to launch another agent.
175
173
  - Run `agency validate` before worktree or pull-request operations.
@@ -189,10 +187,8 @@ resolve its reported commits and remediation commands before retrying.
189
187
 
190
188
  `agency work` is the human launch flow: it reconciles managed integration,
191
189
  selects work, checks readiness, prepares checkouts, marks execution work
192
- `working` without creating a claim, and starts the agent. Epic and multi-phase
193
- task launches remain orchestration-only. External orchestrators instead claim
194
- an execution unit, launch and monitor their agent separately, and finish or
195
- release the claim with the current document revision.
190
+ `working`, and starts the agent. Epic and multi-phase task launches remain
191
+ orchestration-only.
196
192
 
197
193
  An Agency-launched agent receives process-local worker identity through both
198
194
  the `AGENCY_SESSION_ID` and `AGENCY_TARGET` environment variables and a generated
@@ -228,13 +224,11 @@ intent, `--no-pull-request`, and a durable outcome summary.
228
224
  At each closeout trigger (creating or updating a PR, marking it ready, completing
229
225
  a refinement loop, or pausing or handing off completed implementation work):
230
226
 
231
- - Finish an active claim with the current revision via `agency finish`; a
232
- successful claim outcome leaves unmerged work `working`. For unclaimed work,
233
- keep the execution unit `working` through review and merge.
227
+ - Keep the execution unit `working` through review and merge.
234
228
  - After merge, run `agency sync` to reconcile the execution unit to
235
229
  `done`.
236
- - For an approved non-PR outcome, finish an active claim or update unclaimed
237
- status with `--no-pull-request --summary <text>` and optional supporting URL.
230
+ - For an approved non-PR outcome, update status with
231
+ `--no-pull-request --summary <text>` and an optional supporting URL.
238
232
  - Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
239
233
  PR state, current head, diff summary, and verification results after later
240
234
  pushes when those details are maintained there.
@@ -12,9 +12,7 @@ const variables = {
12
12
  target: "execution-unit:phase/task/build",
13
13
  task: "task",
14
14
  phase: "build",
15
- claimant: "orchestrator",
16
15
  sessionId: "session-1",
17
- claimRevision: "revision-1",
18
16
  }
19
17
 
20
18
  describe("agent commands", () => {
@@ -116,7 +114,6 @@ describe("agent commands", () => {
116
114
 
117
115
  expect(environment).toMatchObject({
118
116
  AGENCY_AGENT: "custom",
119
- AGENCY_CLAIMANT: "orchestrator",
120
117
  AGENCY_SESSION_ID: "session-1",
121
118
  AGENCY_WORKBASE: "/workbase",
122
119
  AGENCY_TARGET: "execution-unit:phase/task/build",
@@ -6,9 +6,7 @@ export interface AgentCommandVariables {
6
6
  readonly target: string
7
7
  readonly task: string
8
8
  readonly phase: string
9
- readonly claimant: string
10
9
  readonly sessionId: string
11
- readonly claimRevision: string
12
10
  }
13
11
 
14
12
  interface AgentDefinition {
@@ -25,9 +23,7 @@ const PLACEHOLDERS = new Set<keyof AgentCommandVariables>([
25
23
  "target",
26
24
  "task",
27
25
  "phase",
28
- "claimant",
29
26
  "sessionId",
30
- "claimRevision",
31
27
  ])
32
28
 
33
29
  const BUILTIN_AGENTS: Readonly<Record<string, AgentDefinition>> = {
@@ -122,9 +118,7 @@ export const agentEnvironment = (
122
118
  variables: AgentCommandVariables,
123
119
  ): Record<string, string> => ({
124
120
  AGENCY_AGENT: agent,
125
- AGENCY_CLAIMANT: variables.claimant,
126
121
  AGENCY_SESSION_ID: variables.sessionId,
127
- AGENCY_CLAIM_REVISION: variables.claimRevision,
128
122
  AGENCY_WORKBASE: variables.workbase,
129
123
  AGENCY_TARGET: variables.target,
130
124
  AGENCY_TASK_ID: variables.task,
@@ -3,9 +3,6 @@ import { Data } from "effect"
3
3
  export const documentRevision = (content: string) =>
4
4
  new Bun.CryptoHasher("sha256").update(content).digest("hex")
5
5
 
6
- export const isDocumentRevision = (revision: string) =>
7
- /^[a-f0-9]{64}$/.test(revision)
8
-
9
6
  export class RevisionConflictError extends Data.TaggedError(
10
7
  "RevisionConflictError",
11
8
  )<{
@@ -14,5 +11,4 @@ export class RevisionConflictError extends Data.TaggedError(
14
11
  readonly target?: string
15
12
  readonly expectedRevision: string
16
13
  readonly currentRevision: string
17
- readonly claim?: unknown
18
14
  }> {}