@markjaquith/agency 3.9.6 → 3.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.9.6",
3
+ "version": "3.10.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -17,6 +17,7 @@ import { status } from "./status"
17
17
  import { pr, prCreate } from "./pr"
18
18
  import { validate } from "./validate"
19
19
  import { work as startWork, type StartWork } from "./work"
20
+ import { worktree } from "./worktree"
20
21
  import { macchiato } from "../utils/theme"
21
22
 
22
23
  export type ActEntity = Extract<
@@ -37,6 +38,7 @@ type NativeOperation =
37
38
  | ReturnType<typeof validate>
38
39
  | ReturnType<typeof prCreate>
39
40
  | ReturnType<typeof archive>
41
+ | ReturnType<typeof worktree>
40
42
  | ReturnType<StartWork>
41
43
  type Operation = Effect.Effect<
42
44
  unknown,
@@ -72,6 +74,12 @@ export interface ActAction extends Details {
72
74
  readonly prepare: (prompts: ActionPrompts) => Effect.Effect<Plan, Error>
73
75
  }
74
76
 
77
+ export interface ActCheckoutState {
78
+ readonly paths: readonly string[]
79
+ readonly conflicts: readonly string[]
80
+ readonly dirty: boolean
81
+ }
82
+
75
83
  const actionPresentation: Record<
76
84
  string,
77
85
  {
@@ -205,6 +213,12 @@ const actionPresentation: Record<
205
213
  icon: "",
206
214
  color: macchiato.green,
207
215
  },
216
+ "worktree-remove": {
217
+ description:
218
+ "Remove clean local worktrees while preserving the item, commits, and branches. Agency recreates the checkout when you work on this item again.",
219
+ icon: "󰆴",
220
+ color: macchiato.red,
221
+ },
208
222
  push: {
209
223
  description: "Validate and publish this execution branch without a PR.",
210
224
  icon: "󰜷",
@@ -440,7 +454,11 @@ export const actionGroups = [
440
454
 
441
455
  export const actActions = (
442
456
  nodes: readonly GraphNode[],
443
- options: BaseCommandOptions & { auto?: boolean; draft?: boolean },
457
+ options: BaseCommandOptions & {
458
+ auto?: boolean
459
+ draft?: boolean
460
+ checkoutStates?: ReadonlyMap<string, ActCheckoutState>
461
+ },
444
462
  work: StartWork = startWork,
445
463
  node?: ActEntity,
446
464
  ): readonly ActAction[] => {
@@ -878,6 +896,26 @@ export const actActions = (
878
896
  blockedReason: validation ?? reason,
879
897
  })
880
898
  const noExecution = execution ? null : "Select an execution unit"
899
+ const checkoutState = options.checkoutStates?.get(node.id)
900
+ const activeClaim =
901
+ "claim" in node.data &&
902
+ typeof node.data.claim === "object" &&
903
+ node.data.claim !== null &&
904
+ "state" in node.data.claim &&
905
+ node.data.claim.state === "active"
906
+ const checkoutBlockedReason = noExecution
907
+ ? noExecution
908
+ : !checkoutState
909
+ ? "Local checkout is not materialized"
910
+ : activeClaim
911
+ ? "Item has an active claim; release or finish it before clearing its checkout"
912
+ : node.status === "working" || node.status === "delegated"
913
+ ? `Item has active '${node.status}' ownership; reopen it before clearing its checkout`
914
+ : checkoutState.conflicts.length
915
+ ? checkoutState.conflicts.join("; ")
916
+ : checkoutState.dirty
917
+ ? "Local checkout has uncommitted changes"
918
+ : null
881
919
  const statusCommand = (status: string) => [
882
920
  "agency",
883
921
  target.phaseId ? "phase" : "task",
@@ -937,6 +975,29 @@ export const actActions = (
937
975
  run: Effect.suspend(() => work({ ...options, ...workTarget })),
938
976
  },
939
977
  ),
978
+ action(
979
+ {
980
+ id: "worktree-remove",
981
+ label: "Clear local checkout",
982
+ blockedReason: checkoutBlockedReason,
983
+ },
984
+ undefined,
985
+ (p) =>
986
+ options.dryRun
987
+ ? Effect.void
988
+ : p.confirm(
989
+ `Clear these local worktrees?\n${checkoutState?.paths.map((path) => ` ${path}`).join("\n") ?? ""}\n\nThe item, commits, and branches will be preserved.`,
990
+ "Clear local checkout",
991
+ ),
992
+ () => ({
993
+ command: ["agency", "worktree", "remove", ...args],
994
+ run: worktree({
995
+ ...options,
996
+ subcommand: "remove",
997
+ args,
998
+ }),
999
+ }),
1000
+ ),
940
1001
  immediate(
941
1002
  details(
942
1003
  "push",
@@ -248,6 +248,18 @@ export const actionPrompts = (
248
248
  })
249
249
  return {
250
250
  text,
251
+ confirm: (prompt: string, label = "Confirm") =>
252
+ Effect.gen(function* () {
253
+ const confirmed = yield* interaction.select(
254
+ prompt,
255
+ [
256
+ { key: "confirm", label, value: true },
257
+ { key: "cancel", label: "Cancel", value: false },
258
+ ],
259
+ chooser,
260
+ )
261
+ if (!confirmed) return yield* Effect.fail(new ActCancelled())
262
+ }),
251
263
  id: (label: string, suggestion: string, prefix = "") => {
252
264
  const base = slug(suggestion)
253
265
  let candidate = base
@@ -5,6 +5,7 @@ import { ActDiscovery } from "../act-schema"
5
5
  import { FileSystemService } from "../services/FileSystemService"
6
6
  import { GraphService } from "../services/GraphService"
7
7
  import { WorkbaseService } from "../services/WorkbaseService"
8
+ import { WorktreeService } from "../services/WorktreeService"
8
9
  import type { BaseCommandOptions } from "../utils/command"
9
10
  import type { Choice } from "../utils/chooser"
10
11
  import { createLoggers } from "../utils/effect"
@@ -17,6 +18,7 @@ import {
17
18
  isActActionId,
18
19
  type ActAction,
19
20
  type ActEntity,
21
+ type ActCheckoutState,
20
22
  } from "./act-actions"
21
23
  import {
22
24
  ActCancelled,
@@ -159,6 +161,32 @@ const shellCommand = (argv: readonly string[]) =>
159
161
  )
160
162
  .join(" ")
161
163
  const available = (action: ActAction) => !action.blockedReason
164
+ const checkoutState = (inspection: {
165
+ readonly checkouts: readonly {
166
+ readonly exists: boolean
167
+ readonly path: string
168
+ readonly registeredPath: string | null
169
+ readonly dirty: boolean | null
170
+ }[]
171
+ readonly conflicts: readonly {
172
+ readonly kind: string
173
+ readonly message: string
174
+ }[]
175
+ }): ActCheckoutState => ({
176
+ paths: inspection.checkouts.flatMap((checkout) =>
177
+ checkout.exists
178
+ ? [checkout.path]
179
+ : checkout.registeredPath
180
+ ? [checkout.registeredPath]
181
+ : [],
182
+ ),
183
+ conflicts: inspection.conflicts
184
+ .filter((conflict) => conflict.kind !== "stale-registration")
185
+ .map((conflict) => conflict.message),
186
+ dirty: inspection.checkouts.some(
187
+ (checkout) => checkout.exists && checkout.dirty !== false,
188
+ ),
189
+ })
162
190
  const entitySummary = (node: ActEntity) => ({
163
191
  kind: node.kind,
164
192
  id: node.id,
@@ -170,17 +198,27 @@ const entitySummary = (node: ActEntity) => ({
170
198
  readiness: node.readiness,
171
199
  revision: node.data.sha256,
172
200
  })
201
+
173
202
  const actionChoices = (actions: readonly ActAction[]): Choice<string>[] =>
174
- actions.map(({ id, label, description, icon, color }) => {
203
+ actions.map(({ id, label, description, icon, color, blockedReason }) => {
204
+ const availability = blockedReason ? ` — unavailable: ${blockedReason}` : ""
175
205
  return {
176
206
  key: id,
177
207
  value: id,
178
- label: `${icon} ${label} — ${description}`,
179
- plainLabel: `${label} — ${description}`,
208
+ label: `${icon} ${label} — ${description}${availability}`,
209
+ plainLabel: `${label} — ${description}${availability}`,
180
210
  segments: [
181
211
  { text: `${icon} `, color },
182
212
  { text: label },
183
213
  { text: ` — ${description}`, color: macchiato.overlay0 },
214
+ ...(blockedReason
215
+ ? [
216
+ {
217
+ text: availability,
218
+ color: macchiato.yellow,
219
+ },
220
+ ]
221
+ : []),
184
222
  ],
185
223
  }
186
224
  })
@@ -300,6 +338,7 @@ const actStep = (
300
338
  const fs = yield* FileSystemService
301
339
  const workbase = yield* WorkbaseService
302
340
  const graphs = yield* GraphService
341
+ const worktrees = yield* WorktreeService
303
342
  const { log } = createLoggers(options)
304
343
  const cwd = options.cwd ?? process.cwd()
305
344
  const path = resolve(cwd, options.directory ?? ".")
@@ -313,7 +352,34 @@ const actStep = (
313
352
  const graph = yield* graphs.get({
314
353
  cwd: root,
315
354
  loadedConfig: { root, config },
355
+ include: ["workspace"],
316
356
  })
357
+ const checkoutStates = new Map<string, ActCheckoutState>()
358
+ for (const node of graph.nodes) {
359
+ if (node.kind !== "execution-unit" || !node.workspace?.materialized)
360
+ continue
361
+ const id =
362
+ "phaseId" in node.data
363
+ ? `phase:${node.data.taskId}/${node.data.phaseId}`
364
+ : `task:${node.data.taskId}`
365
+ checkoutStates.set(id, {
366
+ paths: [node.workspace.checkoutPath],
367
+ conflicts: [],
368
+ dirty: false,
369
+ })
370
+ }
371
+ if (options.json && checkoutStates.size > 0) {
372
+ checkoutStates.clear()
373
+ for (const inspection of yield* worktrees.list(root, {
374
+ materializedOnly: true,
375
+ })) {
376
+ const id =
377
+ inspection.owner.kind === "phase"
378
+ ? `phase:${inspection.owner.taskId}/${inspection.owner.phaseId}`
379
+ : `task:${inspection.owner.taskId}`
380
+ checkoutStates.set(id, checkoutState(inspection))
381
+ }
382
+ }
317
383
  const nodes = graph.nodes.filter(
318
384
  (node): node is ActEntity =>
319
385
  node.kind === "epic" || node.kind === "task" || node.kind === "phase",
@@ -341,6 +407,7 @@ const actStep = (
341
407
  inputAllowed: options.inputAllowed,
342
408
  silent: session ? true : options.silent,
343
409
  verbose: options.verbose,
410
+ checkoutStates,
344
411
  }
345
412
  const runWork: StartWork = session
346
413
  ? (args) => work({ ...args, silent: options.silent })
@@ -538,15 +605,39 @@ const actStep = (
538
605
  state.item = selected.id
539
606
  session.activateTab("workstream")
540
607
  }
541
- const actions = selected ? catalog.get(selected.id)! : globals
608
+ let actions = selected ? catalog.get(selected.id)! : globals
542
609
  if (!actionId) {
543
610
  state.view = "item-menu"
611
+ const menuActions = actions.filter(
612
+ (action) =>
613
+ available(action) ||
614
+ (action.id === "worktree-remove" &&
615
+ action.blockedReason !== "Local checkout is not materialized"),
616
+ )
544
617
  actionId = yield* select(
545
618
  `Act on ${selected!.kind} ${selected!.key}`,
546
- actionChoices(actions.filter(available)),
619
+ actionChoices(menuActions),
547
620
  )
548
621
  state.view = "flow"
549
622
  }
623
+ if (
624
+ selected &&
625
+ selected.kind !== "epic" &&
626
+ actionId === "worktree-remove" &&
627
+ !options.json
628
+ ) {
629
+ const inspection = yield* worktrees.inspect(
630
+ selected.kind === "phase"
631
+ ? selected.key.slice(0, selected.key.lastIndexOf("/"))
632
+ : selected.key,
633
+ selected.kind === "phase"
634
+ ? selected.key.slice(selected.key.lastIndexOf("/") + 1)
635
+ : undefined,
636
+ root,
637
+ )
638
+ checkoutStates.set(selected.id, checkoutState(inspection))
639
+ actions = actActions(graph.nodes, nativeOptions, runWork, selected)
640
+ }
550
641
  const action = actions.find((action) => action.id === actionId)
551
642
  if (!action || action.blockedReason)
552
643
  return yield* Effect.fail(