@markjaquith/agency 3.9.6 → 3.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.9.6",
3
+ "version": "3.10.0",
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,
@@ -170,17 +172,27 @@ const entitySummary = (node: ActEntity) => ({
170
172
  readiness: node.readiness,
171
173
  revision: node.data.sha256,
172
174
  })
175
+
173
176
  const actionChoices = (actions: readonly ActAction[]): Choice<string>[] =>
174
- actions.map(({ id, label, description, icon, color }) => {
177
+ actions.map(({ id, label, description, icon, color, blockedReason }) => {
178
+ const availability = blockedReason ? ` — unavailable: ${blockedReason}` : ""
175
179
  return {
176
180
  key: id,
177
181
  value: id,
178
- label: `${icon} ${label} — ${description}`,
179
- plainLabel: `${label} — ${description}`,
182
+ label: `${icon} ${label} — ${description}${availability}`,
183
+ plainLabel: `${label} — ${description}${availability}`,
180
184
  segments: [
181
185
  { text: `${icon} `, color },
182
186
  { text: label },
183
187
  { text: ` — ${description}`, color: macchiato.overlay0 },
188
+ ...(blockedReason
189
+ ? [
190
+ {
191
+ text: availability,
192
+ color: macchiato.yellow,
193
+ },
194
+ ]
195
+ : []),
184
196
  ],
185
197
  }
186
198
  })
@@ -300,6 +312,7 @@ const actStep = (
300
312
  const fs = yield* FileSystemService
301
313
  const workbase = yield* WorkbaseService
302
314
  const graphs = yield* GraphService
315
+ const worktrees = yield* WorktreeService
303
316
  const { log } = createLoggers(options)
304
317
  const cwd = options.cwd ?? process.cwd()
305
318
  const path = resolve(cwd, options.directory ?? ".")
@@ -313,7 +326,35 @@ const actStep = (
313
326
  const graph = yield* graphs.get({
314
327
  cwd: root,
315
328
  loadedConfig: { root, config },
329
+ include: ["workspace"],
316
330
  })
331
+ const checkoutStates = new Map<string, ActCheckoutState>()
332
+ const materialized = graph.nodes.some(
333
+ (node) => node.kind === "execution-unit" && node.workspace?.materialized,
334
+ )
335
+ for (const inspection of materialized
336
+ ? yield* worktrees.list(root, { materializedOnly: true })
337
+ : []) {
338
+ const id =
339
+ inspection.owner.kind === "phase"
340
+ ? `phase:${inspection.owner.taskId}/${inspection.owner.phaseId}`
341
+ : `task:${inspection.owner.taskId}`
342
+ checkoutStates.set(id, {
343
+ paths: inspection.checkouts.flatMap((checkout) =>
344
+ checkout.exists
345
+ ? [checkout.path]
346
+ : checkout.registeredPath
347
+ ? [checkout.registeredPath]
348
+ : [],
349
+ ),
350
+ conflicts: inspection.conflicts
351
+ .filter((conflict) => conflict.kind !== "stale-registration")
352
+ .map((conflict) => conflict.message),
353
+ dirty: inspection.checkouts.some(
354
+ (checkout) => checkout.exists && checkout.dirty !== false,
355
+ ),
356
+ })
357
+ }
317
358
  const nodes = graph.nodes.filter(
318
359
  (node): node is ActEntity =>
319
360
  node.kind === "epic" || node.kind === "task" || node.kind === "phase",
@@ -341,6 +382,7 @@ const actStep = (
341
382
  inputAllowed: options.inputAllowed,
342
383
  silent: session ? true : options.silent,
343
384
  verbose: options.verbose,
385
+ checkoutStates,
344
386
  }
345
387
  const runWork: StartWork = session
346
388
  ? (args) => work({ ...args, silent: options.silent })
@@ -541,9 +583,15 @@ const actStep = (
541
583
  const actions = selected ? catalog.get(selected.id)! : globals
542
584
  if (!actionId) {
543
585
  state.view = "item-menu"
586
+ const menuActions = actions.filter(
587
+ (action) =>
588
+ available(action) ||
589
+ (action.id === "worktree-remove" &&
590
+ action.blockedReason !== "Local checkout is not materialized"),
591
+ )
544
592
  actionId = yield* select(
545
593
  `Act on ${selected!.kind} ${selected!.key}`,
546
- actionChoices(actions.filter(available)),
594
+ actionChoices(menuActions),
547
595
  )
548
596
  state.view = "flow"
549
597
  }