@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 +1 -1
- package/src/commands/act-actions.ts +62 -1
- package/src/commands/act-prompts.ts +12 -0
- package/src/commands/act.ts +96 -5
package/package.json
CHANGED
|
@@ -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 & {
|
|
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
|
package/src/commands/act.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|