@markjaquith/agency 2.14.0 → 2.16.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 +43 -2
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +15 -0
- package/src/cli-parser.ts +15 -0
- package/src/commands/sync.ts +36 -0
- package/src/commands/task.ts +31 -34
- package/src/commands/validate.ts +1 -6
- package/src/commands/work.test.ts +20 -12
- package/src/commands/work.ts +4 -14
- package/src/protocol.ts +5 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/test-utils.ts +2 -0
- package/src/utils/chooser.test.ts +108 -0
- package/src/utils/chooser.ts +222 -0
- package/src/workbase/schemas.test.ts +12 -0
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/work-target.test.ts +10 -0
- package/src/workbase/work-target.ts +45 -35
- package/src/workbase/workbase-choice.ts +12 -37
package/README.md
CHANGED
|
@@ -11,7 +11,6 @@ or write.
|
|
|
11
11
|
- Git
|
|
12
12
|
- [GitHub CLI](https://cli.github.com/) for `agency pr create`
|
|
13
13
|
- OpenCode or Claude Code for `agency work`
|
|
14
|
-
- [fzf](https://github.com/junegunn/fzf) for interactive work target selection
|
|
15
14
|
|
|
16
15
|
## Installation
|
|
17
16
|
|
|
@@ -146,6 +145,29 @@ The configured command applies only to the writable checkout. Supplemental
|
|
|
146
145
|
read-only repositories remain detached Git worktrees at their declared refs so
|
|
147
146
|
they do not acquire writable branches.
|
|
148
147
|
|
|
148
|
+
### Custom Chooser Command
|
|
149
|
+
|
|
150
|
+
Interactive selection uses a native numbered chooser by default. To use an
|
|
151
|
+
external chooser, configure an argv command in `agency.json`:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"version": 2,
|
|
156
|
+
"chooserCommand": ["fzf", "--ansi", "--delimiter=\\t", "--with-nth=2.."]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Agency writes one `key<TAB>label` record per choice to the command's stdin. The
|
|
161
|
+
command must write the selected opaque key or selected record to stdout; commands
|
|
162
|
+
such as `["gum", "filter"]` therefore work without wrappers. Exit codes 1 and
|
|
163
|
+
130, empty stdout, native `q`, and an empty native response cancel selection.
|
|
164
|
+
Other nonzero exits, unknown keys, and invalid native numbers are errors.
|
|
165
|
+
|
|
166
|
+
Selectors are opened only when stdin and stderr are terminals and neither
|
|
167
|
+
`--no-input` nor JSON output is active. Labels use color only when stderr is a
|
|
168
|
+
terminal, `TERM` is not `dumb`, and `NO_COLOR` is unset; otherwise selectors use
|
|
169
|
+
plain labels without ANSI styling or icon-font dependencies.
|
|
170
|
+
|
|
149
171
|
## Frontmatter
|
|
150
172
|
|
|
151
173
|
### Epic
|
|
@@ -282,6 +304,25 @@ inspection are opt-in include layers.
|
|
|
282
304
|
`end` record with counts. Combining the metadata with the streamed node and edge
|
|
283
305
|
records reconstructs the same result as `--json`.
|
|
284
306
|
|
|
307
|
+
### Reconciliation
|
|
308
|
+
|
|
309
|
+
`agency sync` compares every execution declaration with local branch and worktree
|
|
310
|
+
registration, writable and reference checkout dirtiness, resolved reference
|
|
311
|
+
commits, claim expiry, and GitHub pull request and merge state. It reports
|
|
312
|
+
structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
313
|
+
default and `--dry-run` modes are observational.
|
|
314
|
+
|
|
315
|
+
`agency sync --apply` performs only these safe transitions:
|
|
316
|
+
|
|
317
|
+
- materialize missing checkouts when no registration, branch, or path conflicts;
|
|
318
|
+
- release an active claim only after its declared expiry has passed;
|
|
319
|
+
- record a single PR whose head and base match the declaration; and
|
|
320
|
+
- mark work done after its authoritative PR is merged and no active claim remains.
|
|
321
|
+
|
|
322
|
+
Apply never modifies dirty checkouts, moves worktrees, switches branches, resets
|
|
323
|
+
reference commits, chooses among multiple PRs, or bypasses active claims. Those
|
|
324
|
+
conditions remain visible in `warnings` or `unresolved` with a suggested action.
|
|
325
|
+
|
|
285
326
|
### Workbase and Repositories
|
|
286
327
|
|
|
287
328
|
```text
|
|
@@ -302,7 +343,7 @@ repository. Alias names are then used by all documents and commands.
|
|
|
302
343
|
|
|
303
344
|
Commands that print Agency-owned results accept `--json`, including initialization,
|
|
304
345
|
integration inspection/sync, repository mutations, entity creation/list/show,
|
|
305
|
-
status, validation, graph export, and PR creation.
|
|
346
|
+
status, validation, graph export, reconciliation, and PR creation.
|
|
306
347
|
|
|
307
348
|
### Epics
|
|
308
349
|
|
package/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { status, help as statusHelp } from "./src/commands/status"
|
|
|
10
10
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
11
11
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
12
12
|
import { graph, help as graphHelp } from "./src/commands/graph"
|
|
13
|
+
import { sync, help as syncHelp } from "./src/commands/sync"
|
|
13
14
|
import { repo, help as repoHelp } from "./src/commands/repo"
|
|
14
15
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
15
16
|
import { phase, help as phaseHelp } from "./src/commands/phase"
|
|
@@ -33,6 +34,7 @@ import { IntegrationService } from "./src/services/IntegrationService"
|
|
|
33
34
|
import { ContextService } from "./src/services/ContextService"
|
|
34
35
|
import { GraphService } from "./src/services/GraphService"
|
|
35
36
|
import { ClaimService } from "./src/services/ClaimService"
|
|
37
|
+
import { SyncService } from "./src/services/SyncService"
|
|
36
38
|
import {
|
|
37
39
|
claimCommand,
|
|
38
40
|
claimHelp,
|
|
@@ -61,6 +63,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
61
63
|
ContextService.Default,
|
|
62
64
|
GraphService.Default,
|
|
63
65
|
ClaimService.Default,
|
|
66
|
+
SyncService.Default,
|
|
64
67
|
)
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -424,6 +427,23 @@ const commands: Record<string, Command> = {
|
|
|
424
427
|
)
|
|
425
428
|
},
|
|
426
429
|
},
|
|
430
|
+
sync: {
|
|
431
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
432
|
+
if (options.help) {
|
|
433
|
+
console.log(syncHelp)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
await runCommand(
|
|
437
|
+
sync({
|
|
438
|
+
apply: options.apply,
|
|
439
|
+
dryRun: options["dry-run"],
|
|
440
|
+
json: options.json,
|
|
441
|
+
silent: options.silent,
|
|
442
|
+
verbose: options.verbose,
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
},
|
|
446
|
+
},
|
|
427
447
|
}
|
|
428
448
|
|
|
429
449
|
function showMainHelp() {
|
|
@@ -450,6 +470,7 @@ Commands:
|
|
|
450
470
|
validate [path] Validate a workbase
|
|
451
471
|
context [target] Return complete target context
|
|
452
472
|
graph Export the complete workbase graph
|
|
473
|
+
sync Reconcile declarations with external state
|
|
453
474
|
|
|
454
475
|
Global Options:
|
|
455
476
|
-h, --help Show help for a command
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -141,6 +141,7 @@ describe("strict CLI parsing", () => {
|
|
|
141
141
|
[["validate", "one", "two"], "agency validate"],
|
|
142
142
|
[["context", "one", "two"], "agency context"],
|
|
143
143
|
[["graph", "extra"], "agency graph"],
|
|
144
|
+
[["sync", "extra"], "agency sync"],
|
|
144
145
|
[
|
|
145
146
|
[
|
|
146
147
|
"claim",
|
|
@@ -191,6 +192,20 @@ describe("strict CLI parsing", () => {
|
|
|
191
192
|
}
|
|
192
193
|
})
|
|
193
194
|
|
|
195
|
+
test("parses reconciliation modes and rejects conflicting modes", () => {
|
|
196
|
+
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
197
|
+
commandName: "sync",
|
|
198
|
+
values: { "dry-run": true, json: true },
|
|
199
|
+
})
|
|
200
|
+
expect(parseCli(["sync", "--apply"])).toMatchObject({
|
|
201
|
+
commandName: "sync",
|
|
202
|
+
values: { apply: true },
|
|
203
|
+
})
|
|
204
|
+
expect(() => parseCli(["sync", "--dry-run", "--apply"])).toThrow(
|
|
205
|
+
"cannot be combined",
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
194
209
|
test("validates revision-guarded claim operations", () => {
|
|
195
210
|
const revision = "0".repeat(64)
|
|
196
211
|
expect(
|
package/src/cli-parser.ts
CHANGED
|
@@ -319,6 +319,21 @@ const commands = {
|
|
|
319
319
|
required: ["session-id", "revision", "outcome"],
|
|
320
320
|
},
|
|
321
321
|
},
|
|
322
|
+
sync: {
|
|
323
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
324
|
+
options: {
|
|
325
|
+
...outputOptions,
|
|
326
|
+
"dry-run": { type: "boolean" },
|
|
327
|
+
apply: { type: "boolean" },
|
|
328
|
+
},
|
|
329
|
+
command: {
|
|
330
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
331
|
+
minArgs: 0,
|
|
332
|
+
maxArgs: 0,
|
|
333
|
+
options: ["dry-run", "apply", "json"],
|
|
334
|
+
conflicts: [["dry-run", "apply"]],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
322
337
|
archive: {
|
|
323
338
|
usage: "agency archive <epic|task|phase>",
|
|
324
339
|
options: outputOptions,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { SyncService } from "../services/SyncService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface SyncCommandOptions extends BaseCommandOptions {
|
|
7
|
+
readonly apply?: boolean
|
|
8
|
+
readonly dryRun?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const sync = (options: SyncCommandOptions = {}) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const service = yield* SyncService
|
|
14
|
+
const { log } = createLoggers(options)
|
|
15
|
+
const result = yield* service.reconcile({
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
apply: options.apply === true,
|
|
18
|
+
})
|
|
19
|
+
log(JSON.stringify(result, null, 2))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const help = `
|
|
23
|
+
Usage: agency sync [--dry-run | --apply] [--json]
|
|
24
|
+
|
|
25
|
+
Compare declared execution state with Git worktrees, branches, references, claims,
|
|
26
|
+
and GitHub pull requests. Dry-run is the default.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--dry-run Report planned safe transitions without changing state
|
|
30
|
+
--apply Apply safe reconciliation transitions
|
|
31
|
+
--json Output one versioned machine result
|
|
32
|
+
|
|
33
|
+
Apply may materialize unambiguous missing checkouts, release expired claims,
|
|
34
|
+
record a uniquely matched PR, and mark merged work done. Dirty, stale, or
|
|
35
|
+
conflicting checkouts are always left unresolved.
|
|
36
|
+
`
|
package/src/commands/task.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { EpicService } from "../services/EpicService"
|
|
|
6
6
|
import { RepositoryService } from "../services/RepositoryService"
|
|
7
7
|
import { createLoggers } from "../utils/effect"
|
|
8
8
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
9
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
10
|
+
import { choose } from "../utils/chooser"
|
|
9
11
|
|
|
10
12
|
interface TaskOptions extends BaseCommandOptions {
|
|
11
13
|
readonly subcommand?: string
|
|
@@ -29,7 +31,9 @@ export interface TaskInteraction {
|
|
|
29
31
|
) => Effect.Effect<string | null, Error>
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
const defaultInteraction
|
|
34
|
+
const defaultInteraction = (
|
|
35
|
+
chooserCommand?: readonly string[],
|
|
36
|
+
): TaskInteraction => ({
|
|
33
37
|
text: (prompt) =>
|
|
34
38
|
Effect.tryPromise({
|
|
35
39
|
try: async () => {
|
|
@@ -46,37 +50,23 @@ const defaultInteraction: TaskInteraction = {
|
|
|
46
50
|
catch: (cause) => new Error("Failed to read task input", { cause }),
|
|
47
51
|
}),
|
|
48
52
|
select: (prompt, choices) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const [exitCode, output] = await Promise.all([
|
|
60
|
-
process.exited,
|
|
61
|
-
new Response(process.stdout).text(),
|
|
62
|
-
])
|
|
63
|
-
if (exitCode === 1 || exitCode === 130) return null
|
|
64
|
-
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
65
|
-
return output.trim() || null
|
|
66
|
-
},
|
|
67
|
-
catch: (cause) =>
|
|
68
|
-
new Error("Failed to select task input with fzf", { cause }),
|
|
69
|
-
}),
|
|
70
|
-
}
|
|
53
|
+
choose(
|
|
54
|
+
prompt,
|
|
55
|
+
choices.map((choice, index) => ({
|
|
56
|
+
key: String(index),
|
|
57
|
+
label: choice,
|
|
58
|
+
value: choice,
|
|
59
|
+
})),
|
|
60
|
+
chooserCommand,
|
|
61
|
+
),
|
|
62
|
+
})
|
|
71
63
|
|
|
72
|
-
export const task = (
|
|
73
|
-
options: TaskOptions,
|
|
74
|
-
interaction: TaskInteraction = defaultInteraction,
|
|
75
|
-
) =>
|
|
64
|
+
export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
76
65
|
Effect.gen(function* () {
|
|
77
66
|
const tasks = yield* TaskService
|
|
78
67
|
const epics = yield* EpicService
|
|
79
68
|
const repositories = yield* RepositoryService
|
|
69
|
+
const workbase = yield* WorkbaseService
|
|
80
70
|
const { log } = createLoggers(options)
|
|
81
71
|
const cwd = options.cwd ?? process.cwd()
|
|
82
72
|
|
|
@@ -89,8 +79,13 @@ export const task = (
|
|
|
89
79
|
),
|
|
90
80
|
)
|
|
91
81
|
}
|
|
82
|
+
const activeInteraction =
|
|
83
|
+
interaction ??
|
|
84
|
+
defaultInteraction(
|
|
85
|
+
(yield* workbase.loadConfig(cwd)).config.chooserCommand,
|
|
86
|
+
)
|
|
92
87
|
const id =
|
|
93
|
-
options.args[0] ?? (yield*
|
|
88
|
+
options.args[0] ?? (yield* activeInteraction.text("Task ID: ")).trim()
|
|
94
89
|
if (!id) {
|
|
95
90
|
return yield* Effect.fail(new Error("Task ID is required"))
|
|
96
91
|
}
|
|
@@ -103,18 +98,20 @@ export const task = (
|
|
|
103
98
|
|
|
104
99
|
if (options.ticketUrl === undefined) {
|
|
105
100
|
ticketUrl =
|
|
106
|
-
(yield*
|
|
101
|
+
(yield* activeInteraction.text("Ticket URL (optional): ")).trim() ||
|
|
102
|
+
null
|
|
107
103
|
}
|
|
108
104
|
if (options.description === undefined) {
|
|
109
105
|
description =
|
|
110
|
-
(yield*
|
|
111
|
-
|
|
106
|
+
(yield* activeInteraction.text(
|
|
107
|
+
"Description (optional): ",
|
|
108
|
+
)).trim() || undefined
|
|
112
109
|
}
|
|
113
110
|
if (options.epic === undefined) {
|
|
114
111
|
const epicRecords = yield* epics.list(cwd)
|
|
115
112
|
if (epicRecords.length > 0) {
|
|
116
113
|
const none = "(none)"
|
|
117
|
-
const selected = yield*
|
|
114
|
+
const selected = yield* activeInteraction.select("Parent epic", [
|
|
118
115
|
none,
|
|
119
116
|
...epicRecords.map((record) => record.id),
|
|
120
117
|
])
|
|
@@ -125,7 +122,7 @@ export const task = (
|
|
|
125
122
|
}
|
|
126
123
|
}
|
|
127
124
|
if (options.multiPhase === undefined) {
|
|
128
|
-
const selected = yield*
|
|
125
|
+
const selected = yield* activeInteraction.select("Task type", [
|
|
129
126
|
"single-phase",
|
|
130
127
|
"multi-phase",
|
|
131
128
|
])
|
|
@@ -144,7 +141,7 @@ export const task = (
|
|
|
144
141
|
),
|
|
145
142
|
)
|
|
146
143
|
}
|
|
147
|
-
const selected = yield*
|
|
144
|
+
const selected = yield* activeInteraction.select(
|
|
148
145
|
"Writable repository",
|
|
149
146
|
records.map((record) => record.alias),
|
|
150
147
|
)
|
package/src/commands/validate.ts
CHANGED
|
@@ -32,12 +32,7 @@ export const validate = (
|
|
|
32
32
|
const startPath = options.path ?? options.cwd ?? process.cwd()
|
|
33
33
|
const root = options.path
|
|
34
34
|
? yield* workbase.discover(startPath)
|
|
35
|
-
: yield* resolveWorkbase(
|
|
36
|
-
startPath,
|
|
37
|
-
log,
|
|
38
|
-
pick,
|
|
39
|
-
options.inputAllowed ?? true,
|
|
40
|
-
)
|
|
35
|
+
: yield* resolveWorkbase(startPath, pick, options.inputAllowed ?? true)
|
|
41
36
|
if (!root) return
|
|
42
37
|
const report = yield* workbase.validate(root)
|
|
43
38
|
|
|
@@ -46,7 +46,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
46
46
|
interface HarnessOptions {
|
|
47
47
|
readonly workspace?: ExecutionWorkspace
|
|
48
48
|
readonly materializeError?: Error
|
|
49
|
-
readonly available?: Partial<Record<"opencode" | "claude"
|
|
49
|
+
readonly available?: Partial<Record<"opencode" | "claude", boolean>>
|
|
50
|
+
readonly chooserCommand?: readonly string[]
|
|
50
51
|
readonly multiPhaseTasks?: readonly string[]
|
|
51
52
|
readonly epicRecords?: readonly any[]
|
|
52
53
|
readonly taskRecords?: readonly any[]
|
|
@@ -93,6 +94,14 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
93
94
|
})
|
|
94
95
|
: Effect.succeed("/workbase"),
|
|
95
96
|
listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
|
|
97
|
+
loadConfig: () =>
|
|
98
|
+
Effect.succeed({
|
|
99
|
+
root: "/workbase",
|
|
100
|
+
config: {
|
|
101
|
+
version: 2 as const,
|
|
102
|
+
chooserCommand: options.chooserCommand,
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
96
105
|
}
|
|
97
106
|
const epics = {
|
|
98
107
|
show: (id: string) =>
|
|
@@ -359,7 +368,7 @@ describe("work command", () => {
|
|
|
359
368
|
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
360
369
|
})
|
|
361
370
|
|
|
362
|
-
test("selects a target
|
|
371
|
+
test("selects a target when no directory is provided", async () => {
|
|
363
372
|
const phase = {
|
|
364
373
|
taskId: "delivery",
|
|
365
374
|
id: "build",
|
|
@@ -385,7 +394,6 @@ describe("work command", () => {
|
|
|
385
394
|
await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
|
|
386
395
|
|
|
387
396
|
expect(harness.events).toEqual([
|
|
388
|
-
"probe:fzf",
|
|
389
397
|
"materialize",
|
|
390
398
|
"probe:opencode",
|
|
391
399
|
"launch:opencode",
|
|
@@ -442,7 +450,6 @@ describe("work command", () => {
|
|
|
442
450
|
|
|
443
451
|
expect(selections).toEqual([["/first", "/workbase"]])
|
|
444
452
|
expect(harness.events).toEqual([
|
|
445
|
-
"probe:fzf",
|
|
446
453
|
"materialize",
|
|
447
454
|
"probe:opencode",
|
|
448
455
|
"launch:opencode",
|
|
@@ -476,9 +483,9 @@ describe("work command", () => {
|
|
|
476
483
|
expect(harness.events).toEqual([])
|
|
477
484
|
})
|
|
478
485
|
|
|
479
|
-
test("
|
|
486
|
+
test("passes the configured chooser command to the shared picker", async () => {
|
|
480
487
|
const harness = createHarness({
|
|
481
|
-
|
|
488
|
+
chooserCommand: ["gum", "filter"],
|
|
482
489
|
epicRecords: [
|
|
483
490
|
{
|
|
484
491
|
id: "delivery",
|
|
@@ -487,14 +494,15 @@ describe("work command", () => {
|
|
|
487
494
|
},
|
|
488
495
|
],
|
|
489
496
|
})
|
|
497
|
+
let command: readonly string[] | undefined
|
|
498
|
+
const pick: PickWorkTarget = (_choices, chooserCommand) => {
|
|
499
|
+
command = chooserCommand
|
|
500
|
+
return Effect.succeed(null)
|
|
501
|
+
}
|
|
490
502
|
|
|
491
|
-
|
|
492
|
-
await expect(harness.run({ cwd: "/workbase" })).rejects.toThrow(
|
|
493
|
-
"fzf is required",
|
|
494
|
-
)
|
|
495
|
-
})
|
|
503
|
+
await harness.run({ cwd: "/workbase" }, pick)
|
|
496
504
|
|
|
497
|
-
expect(
|
|
505
|
+
expect(command).toEqual(["gum", "filter"])
|
|
498
506
|
expect(harness.launches).toEqual([])
|
|
499
507
|
})
|
|
500
508
|
|
package/src/commands/work.ts
CHANGED
|
@@ -89,7 +89,7 @@ export const work = (
|
|
|
89
89
|
: false
|
|
90
90
|
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
91
91
|
const inputAllowed = options.inputAllowed ?? true
|
|
92
|
-
const root = yield* resolveWorkbase(startPath,
|
|
92
|
+
const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
|
|
93
93
|
if (!root) return
|
|
94
94
|
|
|
95
95
|
let target: WorkTarget | null = null
|
|
@@ -178,18 +178,8 @@ export const work = (
|
|
|
178
178
|
new Error("No epics, tasks, or phases found in this workbase"),
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
})
|
|
184
|
-
if (fzf.exitCode !== 0) {
|
|
185
|
-
for (const choice of choices) log(choice.label)
|
|
186
|
-
return yield* Effect.fail(
|
|
187
|
-
new Error(
|
|
188
|
-
"fzf is required to select a work target; install fzf or provide a directory explicitly",
|
|
189
|
-
),
|
|
190
|
-
)
|
|
191
|
-
}
|
|
192
|
-
target = yield* pick(choices)
|
|
181
|
+
const { config } = yield* workbase.loadConfig(root)
|
|
182
|
+
target = yield* pick(choices, config.chooserCommand)
|
|
193
183
|
if (!target) return
|
|
194
184
|
}
|
|
195
185
|
|
|
@@ -332,7 +322,7 @@ Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
|
332
322
|
agency work prepare [target] [--dry-run] [--json]
|
|
333
323
|
|
|
334
324
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
335
|
-
|
|
325
|
+
interactively. A positional argument resolves as a directory first, then as a task
|
|
336
326
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
337
327
|
workbase first.
|
|
338
328
|
|
package/src/protocol.ts
CHANGED
|
@@ -111,6 +111,11 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
111
111
|
retryable: false,
|
|
112
112
|
remediation: "Correct the workbase graph data or filters and retry.",
|
|
113
113
|
},
|
|
114
|
+
SyncError: {
|
|
115
|
+
code: "SYNC_ERROR",
|
|
116
|
+
retryable: false,
|
|
117
|
+
remediation: "Resolve workbase validation errors before reconciling.",
|
|
118
|
+
},
|
|
114
119
|
ProcessError: { code: "PROCESS_ERROR", retryable: true },
|
|
115
120
|
ProtocolOutputError: {
|
|
116
121
|
code: "PROTOCOL_OUTPUT_ERROR",
|
|
@@ -87,6 +87,23 @@ interface FinishInput extends OwnedClaimInput {
|
|
|
87
87
|
readonly outcome: "done" | "dropped"
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
interface ExpireClaimInput {
|
|
91
|
+
readonly taskId: string
|
|
92
|
+
readonly phaseId?: string
|
|
93
|
+
readonly revision: string
|
|
94
|
+
readonly now?: Date
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ReconcileInput {
|
|
98
|
+
readonly taskId: string
|
|
99
|
+
readonly phaseId?: string
|
|
100
|
+
readonly revision: string
|
|
101
|
+
readonly pr?: string
|
|
102
|
+
readonly status?: "done"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
106
|
+
|
|
90
107
|
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
91
108
|
type ExecutionData = SingleTaskData | PhaseData
|
|
92
109
|
|
|
@@ -286,6 +303,83 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
286
303
|
}
|
|
287
304
|
}),
|
|
288
305
|
|
|
306
|
+
expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const service = yield* ClaimService
|
|
309
|
+
const inspected = yield* service.inspect(
|
|
310
|
+
input.taskId,
|
|
311
|
+
input.phaseId,
|
|
312
|
+
startPath,
|
|
313
|
+
)
|
|
314
|
+
return yield* operation(() =>
|
|
315
|
+
updateAtomically(
|
|
316
|
+
inspected.target,
|
|
317
|
+
input.revision,
|
|
318
|
+
(data, now) => {
|
|
319
|
+
if (
|
|
320
|
+
data.claim?.state !== "active" ||
|
|
321
|
+
data.claim.expiresAt === undefined ||
|
|
322
|
+
Date.parse(data.claim.expiresAt) > now.getTime() ||
|
|
323
|
+
(data.status !== "working" && data.status !== "delegated")
|
|
324
|
+
) {
|
|
325
|
+
throw new ClaimError({
|
|
326
|
+
target: inspected.target.label,
|
|
327
|
+
message: `${inspected.target.label} does not have an expired active claim`,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
const claim: ClaimRecord = {
|
|
331
|
+
...data.claim,
|
|
332
|
+
state: "released",
|
|
333
|
+
releasedAt: now.toISOString(),
|
|
334
|
+
}
|
|
335
|
+
return { data: { ...data, status: "open", claim }, claim }
|
|
336
|
+
},
|
|
337
|
+
input.now ?? new Date(),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
}),
|
|
341
|
+
|
|
342
|
+
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
343
|
+
Effect.gen(function* () {
|
|
344
|
+
if (input.pr !== undefined && !PR_URL.test(input.pr)) {
|
|
345
|
+
return yield* new ClaimError({
|
|
346
|
+
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
const service = yield* ClaimService
|
|
350
|
+
const inspected = yield* service.inspect(
|
|
351
|
+
input.taskId,
|
|
352
|
+
input.phaseId,
|
|
353
|
+
startPath,
|
|
354
|
+
)
|
|
355
|
+
return yield* operation(() =>
|
|
356
|
+
updateAtomically(
|
|
357
|
+
inspected.target,
|
|
358
|
+
input.revision,
|
|
359
|
+
(data) => {
|
|
360
|
+
if (input.status === "done" && data.claim?.state === "active") {
|
|
361
|
+
throw new ClaimConflictError({
|
|
362
|
+
target: inspected.target.label,
|
|
363
|
+
currentRevision: input.revision,
|
|
364
|
+
claim: data.claim,
|
|
365
|
+
message: `${inspected.target.label} has an active claim`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
data: {
|
|
370
|
+
...data,
|
|
371
|
+
...(input.pr !== undefined ? { pr: input.pr } : {}),
|
|
372
|
+
...(input.status !== undefined
|
|
373
|
+
? { status: input.status }
|
|
374
|
+
: {}),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
new Date(),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
}),
|
|
382
|
+
|
|
289
383
|
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
290
384
|
Effect.gen(function* () {
|
|
291
385
|
for (const [label, value] of [
|