@markjaquith/agency 2.48.1 → 2.49.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 CHANGED
@@ -610,7 +610,8 @@ Inspect tasks:
610
610
  ```text
611
611
  agency task list [filters] [--json]
612
612
  agency task show <id> [--json]
613
- agency task status <id> <open|working|dropped> [--json]
613
+ agency task status <id> <open|working|done|dropped>
614
+ [--no-pull-request --summary <text> [--evidence-url <url>]] [--json]
614
615
  agency task update <id> [metadata options] [--json]
615
616
  agency task rename <id> <new-id> [--json]
616
617
  agency task move <id> (--epic <epic-id> | --no-epic) [--json]
@@ -649,7 +650,8 @@ agency phase create <task-id> <phase-id>
649
650
 
650
651
  agency phase list <task-id> [filters] [--json]
651
652
  agency phase show <task-id> <phase-id> [--json]
652
- agency phase status <task-id> <phase-id> <open|working|dropped> [--json]
653
+ agency phase status <task-id> <phase-id> <open|working|done|dropped>
654
+ [--no-pull-request --summary <text> [--evidence-url <url>]] [--json]
653
655
  agency phase update <task-id> <phase-id> [metadata options] [--json]
654
656
  agency phase rename <task-id> <phase-id> <new-id> [--json]
655
657
  agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>
@@ -672,8 +674,12 @@ writing anything.
672
674
  Single-phase tasks and phases store status in YAML. New execution units start
673
675
  `open`, and `agency work` marks the selected execution unit `working` immediately
674
676
  before launch. Running `agency work` again can relaunch unclaimed `working` work.
675
- The `done` status is reserved for an authoritative merged pull request and is
676
- applied by `agency sync --apply`, not by task or phase status commands.
677
+ By default, `done` requires an authoritative merged pull request and is applied
678
+ by `agency sync --apply`. Work whose intended outcome genuinely requires no pull
679
+ request may instead use an explicit `--no-pull-request --summary <text>` status
680
+ transition. Agency records the summary, completion time, and optional evidence
681
+ URL durably; reopening removes that evidence. This exceptional path refuses work
682
+ that already has a recorded pull request.
677
683
  Use explicit claims only when an external orchestrator needs coordinated
678
684
  ownership. The interactive work selector displays status markers before
679
685
  execution units. Existing working and delegated work may be released to `open`
@@ -704,15 +710,19 @@ agency claim <task-id> [phase-id] --claimant <id> --runner <id>
704
710
  agency release <task-id> [phase-id] --session-id <id>
705
711
  --revision <sha256> [--json]
706
712
  agency finish <task-id> [phase-id] --session-id <id>
707
- --revision <sha256> --outcome <done|dropped> [--json]
713
+ --revision <sha256> --outcome <done|dropped>
714
+ [--no-pull-request --summary <text> [--evidence-url <url>]] [--json]
708
715
  ```
709
716
 
710
717
  An active claim sets status to `working`. Release returns it to `open`. Finish
711
718
  records the claim outcome and ownership history; a `done` claim outcome leaves
712
719
  the execution unit `working` until its pull request is merged, while `dropped`
713
- remains terminal. Conflicts return the current revision and complete ownership
714
- record in the machine error envelope rather than overwriting it. Expired claims
715
- may be replaced with a revision-guarded claim.
720
+ remains terminal. For a genuine non-PR outcome, `--no-pull-request` atomically
721
+ records completion evidence, finishes the claim, and sets the execution unit to
722
+ `done`; `--summary` is required and `--evidence-url` is optional. Conflicts return
723
+ the current revision and complete ownership record in the machine error envelope
724
+ rather than overwriting it. Expired claims may be replaced with a
725
+ revision-guarded claim.
716
726
 
717
727
  `agency work` does not claim execution units. It refuses active explicit claims,
718
728
  marks open execution work `working`, and launches the runner. External
package/cli.ts CHANGED
@@ -205,6 +205,9 @@ const commands: Record<string, Command> = {
205
205
  sessionId: options["session-id"],
206
206
  revision: options.revision,
207
207
  outcome: options.outcome,
208
+ noPullRequest: options["no-pull-request"],
209
+ summary: options.summary,
210
+ evidenceUrl: options["evidence-url"],
208
211
  json: options.json,
209
212
  silent: options.silent,
210
213
  verbose: options.verbose,
@@ -298,6 +301,9 @@ const commands: Record<string, Command> = {
298
301
  clearReferences: options["clear-references"],
299
302
  prUrl: options["pr-url"],
300
303
  clearPr: options["clear-pr"],
304
+ noPullRequest: options["no-pull-request"],
305
+ summary: options.summary,
306
+ evidenceUrl: options["evidence-url"],
301
307
  ifRevision: options["if-revision"],
302
308
  dependsOn: options["depends-on"],
303
309
  firstPhase: options["first-phase"],
@@ -449,6 +455,9 @@ const commands: Record<string, Command> = {
449
455
  clearReferences: options["clear-references"],
450
456
  prUrl: options["pr-url"],
451
457
  clearPr: options["clear-pr"],
458
+ noPullRequest: options["no-pull-request"],
459
+ summary: options.summary,
460
+ evidenceUrl: options["evidence-url"],
452
461
  ifRevision: options["if-revision"],
453
462
  noEpic: options["no-epic"],
454
463
  multiPhase: options["multi-phase"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.48.1",
3
+ "version": "2.49.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -362,6 +362,64 @@ describe("strict CLI parsing", () => {
362
362
  "working",
363
363
  ]),
364
364
  ).toThrow("must be 'done' or 'dropped'")
365
+ expect(
366
+ parseCli([
367
+ "finish",
368
+ "task",
369
+ "--session-id",
370
+ "job-1",
371
+ "--revision",
372
+ revision,
373
+ "--outcome",
374
+ "done",
375
+ "--no-pull-request",
376
+ "--summary",
377
+ "Investigation completed",
378
+ "--evidence-url",
379
+ "https://example.com/result",
380
+ ]),
381
+ ).toMatchObject({
382
+ values: {
383
+ "no-pull-request": true,
384
+ summary: "Investigation completed",
385
+ "evidence-url": "https://example.com/result",
386
+ },
387
+ })
388
+ expect(() =>
389
+ parseCli([
390
+ "finish",
391
+ "task",
392
+ "--session-id",
393
+ "job-1",
394
+ "--revision",
395
+ revision,
396
+ "--outcome",
397
+ "done",
398
+ "--no-pull-request",
399
+ ]),
400
+ ).toThrow("--summary' is required")
401
+ expect(() =>
402
+ parseCli([
403
+ "task",
404
+ "status",
405
+ "example",
406
+ "working",
407
+ "--no-pull-request",
408
+ "--summary",
409
+ "Not done",
410
+ ]),
411
+ ).toThrow("valid only with a done status")
412
+ expect(() =>
413
+ parseCli([
414
+ "phase",
415
+ "status",
416
+ "task",
417
+ "phase",
418
+ "done",
419
+ "--summary",
420
+ "Missing explicit flag",
421
+ ]),
422
+ ).toThrow("require '--no-pull-request'")
365
423
  })
366
424
 
367
425
  test("accepts valid mutation revisions and rejects malformed hashes", () => {
package/src/cli-parser.ts CHANGED
@@ -120,6 +120,12 @@ const ownedClaimOptions = {
120
120
  revision: { type: "string" },
121
121
  } satisfies OptionConfig
122
122
 
123
+ const nonPrCompletionOptions = {
124
+ "no-pull-request": { type: "boolean" },
125
+ summary: { type: "string" },
126
+ "evidence-url": { type: "string" },
127
+ } satisfies OptionConfig
128
+
123
129
  const commands = {
124
130
  init: {
125
131
  usage: "agency init [path] [--json]",
@@ -360,6 +366,7 @@ const commands = {
360
366
  ...newWorkOptions,
361
367
  ...viewOptions,
362
368
  ...mutationOptions,
369
+ ...nonPrCompletionOptions,
363
370
  "pr-url": { type: "string" },
364
371
  task: { type: "string" },
365
372
  },
@@ -417,10 +424,10 @@ const commands = {
417
424
  options: ["json", "task"],
418
425
  },
419
426
  status: {
420
- usage: "agency task status <id> <status> [--json]",
427
+ usage: "agency task status <id> <status> [options] [--json]",
421
428
  minArgs: 2,
422
429
  maxArgs: 2,
423
- options: ["json", "task"],
430
+ options: ["json", "task", "no-pull-request", "summary", "evidence-url"],
424
431
  },
425
432
  update: {
426
433
  usage: "agency task update <id> [options] [--json]",
@@ -479,6 +486,7 @@ const commands = {
479
486
  ...newWorkOptions,
480
487
  ...viewOptions,
481
488
  ...mutationOptions,
489
+ ...nonPrCompletionOptions,
482
490
  "pr-url": { type: "string" },
483
491
  task: { type: "string" },
484
492
  phase: { type: "string" },
@@ -542,10 +550,18 @@ const commands = {
542
550
  options: ["json", "task", "phase"],
543
551
  },
544
552
  status: {
545
- usage: "agency phase status <task-id> <phase-id> <status> [--json]",
553
+ usage:
554
+ "agency phase status <task-id> <phase-id> <status> [options] [--json]",
546
555
  minArgs: 3,
547
556
  maxArgs: 3,
548
- options: ["json", "task", "phase"],
557
+ options: [
558
+ "json",
559
+ "task",
560
+ "phase",
561
+ "no-pull-request",
562
+ "summary",
563
+ "evidence-url",
564
+ ],
549
565
  },
550
566
  update: {
551
567
  usage: "agency phase update <task-id> <phase-id> [options] [--json]",
@@ -631,16 +647,27 @@ const commands = {
631
647
  usage: "agency finish <task-id> [phase-id] [options]",
632
648
  options: {
633
649
  ...ownedClaimOptions,
650
+ ...nonPrCompletionOptions,
634
651
  outcome: { type: "string" },
635
652
  task: { type: "string" },
636
653
  phase: { type: "string" },
637
654
  },
638
655
  command: {
639
656
  usage:
640
- "agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [--json]",
657
+ "agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [options] [--json]",
641
658
  minArgs: 1,
642
659
  maxArgs: 2,
643
- options: ["session-id", "revision", "outcome", "json", "task", "phase"],
660
+ options: [
661
+ "session-id",
662
+ "revision",
663
+ "outcome",
664
+ "no-pull-request",
665
+ "summary",
666
+ "evidence-url",
667
+ "json",
668
+ "task",
669
+ "phase",
670
+ ],
644
671
  required: ["session-id", "revision", "outcome"],
645
672
  },
646
673
  },
@@ -1409,6 +1436,40 @@ export function parseCli(args: readonly string[]): ParsedCli {
1409
1436
  spec.usage,
1410
1437
  )
1411
1438
  }
1439
+ const nonPrCompletion = parsed.values["no-pull-request"] === true
1440
+ const completionSummary = parsed.values.summary
1441
+ const completionEvidenceUrl = parsed.values["evidence-url"]
1442
+ if (
1443
+ (completionSummary !== undefined || completionEvidenceUrl !== undefined) &&
1444
+ !nonPrCompletion
1445
+ ) {
1446
+ throw usageError(
1447
+ "Options '--summary' and '--evidence-url' require '--no-pull-request'.",
1448
+ spec.usage,
1449
+ )
1450
+ }
1451
+ if (nonPrCompletion) {
1452
+ if (
1453
+ typeof completionSummary !== "string" ||
1454
+ completionSummary.trim().length === 0
1455
+ ) {
1456
+ throw usageError(
1457
+ "Option '--summary' is required with '--no-pull-request'.",
1458
+ spec.usage,
1459
+ )
1460
+ }
1461
+ const completesDone =
1462
+ (commandName === "finish" && parsed.values.outcome === "done") ||
1463
+ (["task", "phase"].includes(commandName) &&
1464
+ subcommand === "status" &&
1465
+ commandPositionals.at(-1) === "done")
1466
+ if (!completesDone) {
1467
+ throw usageError(
1468
+ "Option '--no-pull-request' is valid only with a done status or outcome.",
1469
+ spec.usage,
1470
+ )
1471
+ }
1472
+ }
1412
1473
  if (commandName === "work") {
1413
1474
  const preparing = commandPositionals[0] === "prepare"
1414
1475
  if (
package/src/cli.test.ts CHANGED
@@ -248,6 +248,77 @@ describe("CLI", () => {
248
248
  parseJson(await runCli(["task", "show", "claimed", "--json"], root)).data
249
249
  .status,
250
250
  ).toBe("working")
251
+
252
+ parseJson(
253
+ await runCli(
254
+ ["task", "create", "non-pr", "--repo", "agency", "--json"],
255
+ root,
256
+ ),
257
+ )
258
+ const nonPrContext = parseJson(
259
+ await runCli(["context", "tasks/non-pr", "--json"], root),
260
+ )
261
+ const nonPrClaim = parseJson(
262
+ await runCli(
263
+ [
264
+ "claim",
265
+ "non-pr",
266
+ "--claimant",
267
+ "orchestrator",
268
+ "--runner",
269
+ "agent",
270
+ "--session-id",
271
+ "job-2",
272
+ "--revision",
273
+ nonPrContext.documents.task.sha256,
274
+ "--json",
275
+ ],
276
+ root,
277
+ ),
278
+ )
279
+ parseJson(
280
+ await runCli(
281
+ [
282
+ "finish",
283
+ "non-pr",
284
+ "--session-id",
285
+ "job-2",
286
+ "--revision",
287
+ nonPrClaim.revision,
288
+ "--outcome",
289
+ "done",
290
+ "--no-pull-request",
291
+ "--summary",
292
+ "Investigation completed without changes.",
293
+ "--evidence-url",
294
+ "https://example.com/result",
295
+ "--json",
296
+ ],
297
+ root,
298
+ ),
299
+ )
300
+ expect(
301
+ parseJson(await runCli(["task", "show", "non-pr", "--json"], root)).data,
302
+ ).toMatchObject({
303
+ status: "done",
304
+ completion: {
305
+ mode: "non-pr",
306
+ summary: "Investigation completed without changes.",
307
+ evidenceUrl: "https://example.com/result",
308
+ },
309
+ })
310
+ const prUpdate = await runCli(
311
+ [
312
+ "task",
313
+ "update",
314
+ "non-pr",
315
+ "--pr-url",
316
+ "https://github.com/example/agency/pull/1",
317
+ ],
318
+ root,
319
+ )
320
+ expect(prUpdate.exitCode).toBe(1)
321
+ expect(prUpdate.stderr).toContain("Reopen non-PR completed work")
251
322
  })
252
323
 
253
324
  test("routes graph mutations through structured output", async () => {
@@ -13,6 +13,9 @@ interface ClaimCommandOptions extends BaseCommandOptions {
13
13
  readonly revision?: string
14
14
  readonly expiresAt?: string
15
15
  readonly outcome?: string
16
+ readonly noPullRequest?: boolean
17
+ readonly summary?: string
18
+ readonly evidenceUrl?: string
16
19
  readonly json?: boolean
17
20
  }
18
21
 
@@ -32,6 +35,16 @@ export const claimCommand = (options: ClaimCommandOptions) =>
32
35
  new Error("Claimant and runner identities are required"),
33
36
  )
34
37
  }
38
+ if (options.noPullRequest && !options.summary?.trim()) {
39
+ return yield* Effect.fail(
40
+ new Error("Non-PR completion requires a non-empty summary"),
41
+ )
42
+ }
43
+ if (options.noPullRequest && options.outcome !== "done") {
44
+ return yield* Effect.fail(
45
+ new Error("Non-PR completion is valid only with a done outcome"),
46
+ )
47
+ }
35
48
  if (
36
49
  options.operation === "finish" &&
37
50
  options.outcome !== "done" &&
@@ -65,6 +78,16 @@ export const claimCommand = (options: ClaimCommandOptions) =>
65
78
  {
66
79
  ...common,
67
80
  outcome: options.outcome as "done" | "dropped",
81
+ ...(options.noPullRequest
82
+ ? {
83
+ nonPrCompletion: {
84
+ summary: options.summary!,
85
+ ...(options.evidenceUrl
86
+ ? { evidenceUrl: options.evidenceUrl }
87
+ : {}),
88
+ },
89
+ }
90
+ : {}),
68
91
  },
69
92
  cwd,
70
93
  )
@@ -91,8 +114,9 @@ Release an execution unit owned by the session.
91
114
  `
92
115
 
93
116
  export const finishHelp = `
94
- Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
117
+ Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [--no-pull-request --summary <text> [--evidence-url <url>]]
95
118
 
96
119
  Finish a claim owned by the session. A done claim outcome leaves unmerged work
97
- working; agency sync --apply marks the execution unit done after merge.
120
+ working; agency sync --apply marks the execution unit done after merge. Use
121
+ --no-pull-request with a summary for an explicit non-PR completion.
98
122
  `
@@ -31,6 +31,9 @@ interface PhaseOptions extends BaseCommandOptions {
31
31
  readonly pr?: boolean
32
32
  readonly work?: boolean
33
33
  readonly auto?: boolean
34
+ readonly noPullRequest?: boolean
35
+ readonly summary?: string
36
+ readonly evidenceUrl?: string
34
37
  }
35
38
 
36
39
  export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
@@ -166,7 +169,20 @@ export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
166
169
  ),
167
170
  )
168
171
  }
169
- const record = yield* phases.setStatus(taskId, phaseId, status, cwd)
172
+ const record = yield* phases.setStatus(
173
+ taskId,
174
+ phaseId,
175
+ status,
176
+ cwd,
177
+ options.noPullRequest
178
+ ? {
179
+ summary: options.summary ?? "",
180
+ ...(options.evidenceUrl
181
+ ? { evidenceUrl: options.evidenceUrl }
182
+ : {}),
183
+ }
184
+ : undefined,
185
+ )
170
186
  const { content: _, ...output } = record
171
187
  log(
172
188
  options.json
@@ -275,7 +291,7 @@ Subcommands:
275
291
  list <task> List task phases
276
292
  show <task> <phase> Show a phase
277
293
  status <task> <phase> <status>
278
- Set open, working, or dropped
294
+ Set open, working, dropped, or explicit non-PR done
279
295
  update <task> <phase> Update phase metadata
280
296
  rename <task> <phase> <new-id>
281
297
  Rename a phase and update dependencies
@@ -285,6 +301,11 @@ Subcommands:
285
301
  Mutation option:
286
302
  --if-revision <hash> Require the target's current revision
287
303
 
304
+ Non-PR completion options (status done only):
305
+ --no-pull-request Complete without a pull request
306
+ --summary <text> Required durable outcome summary
307
+ --evidence-url <url> Optional supporting record
308
+
288
309
  Create options:
289
310
  --description <text> Short description of the phase
290
311
  --repo <alias> Writable repository
@@ -38,6 +38,9 @@ interface TaskOptions extends BaseCommandOptions {
38
38
  readonly pr?: boolean
39
39
  readonly work?: boolean
40
40
  readonly auto?: boolean
41
+ readonly noPullRequest?: boolean
42
+ readonly summary?: string
43
+ readonly evidenceUrl?: string
41
44
  }
42
45
 
43
46
  export interface TaskInteraction {
@@ -304,7 +307,19 @@ export const task = (
304
307
  new Error("Usage: agency task status <id> <status>"),
305
308
  )
306
309
  }
307
- const record = yield* tasks.setStatus(id, status, cwd)
310
+ const record = yield* tasks.setStatus(
311
+ id,
312
+ status,
313
+ cwd,
314
+ options.noPullRequest
315
+ ? {
316
+ summary: options.summary ?? "",
317
+ ...(options.evidenceUrl
318
+ ? { evidenceUrl: options.evidenceUrl }
319
+ : {}),
320
+ }
321
+ : undefined,
322
+ )
308
323
  const { content: _, ...output } = record
309
324
  log(
310
325
  options.json
@@ -423,7 +438,7 @@ Subcommands:
423
438
  create <id> Create a task without prompting
424
439
  list List tasks
425
440
  show <id> Show a task
426
- status <id> <status> Set open, working, or dropped
441
+ status <id> <status> Set open, working, dropped, or explicit non-PR done
427
442
  update <id> Update task metadata
428
443
  rename <id> <new-id> Rename a task and update graph references
429
444
  move <id> Move a task with --epic or --no-epic
@@ -433,6 +448,11 @@ Subcommands:
433
448
  Mutation option:
434
449
  --if-revision <hash> Require the target's current revision
435
450
 
451
+ Non-PR completion options (status done only):
452
+ --no-pull-request Complete without a pull request
453
+ --summary <text> Required durable outcome summary
454
+ --evidence-url <url> Optional supporting record
455
+
436
456
  Create options:
437
457
  --ticket-url <url> External ticket URL (optional)
438
458
  --description <text> Short description of the task
@@ -147,6 +147,65 @@ describe("claim service", () => {
147
147
  })
148
148
  })
149
149
 
150
+ test("finishes claimed non-PR work with durable completion evidence", async () => {
151
+ const initial = await inspect()
152
+ const acquired = await claim(initial.revision)
153
+ const finished = await runTestEffect(
154
+ ClaimService.pipe(
155
+ Effect.flatMap((service) =>
156
+ service.finish(
157
+ {
158
+ taskId: "single",
159
+ sessionId: "session-1",
160
+ revision: acquired.revision,
161
+ outcome: "done",
162
+ nonPrCompletion: {
163
+ summary: "Review completed; no code change was needed.",
164
+ evidenceUrl: "https://example.com/review",
165
+ },
166
+ now: at("2026-07-17T12:45:00.000Z"),
167
+ },
168
+ root,
169
+ ),
170
+ ),
171
+ ),
172
+ )
173
+
174
+ expect(finished.data).toMatchObject({
175
+ status: "done",
176
+ claim: { state: "finished", outcome: "done" },
177
+ completion: {
178
+ mode: "non-pr",
179
+ completedAt: "2026-07-17T12:45:00.000Z",
180
+ summary: "Review completed; no code change was needed.",
181
+ evidenceUrl: "https://example.com/review",
182
+ },
183
+ })
184
+ })
185
+
186
+ test("rejects invalid claimed non-PR completion", async () => {
187
+ const initial = await inspect()
188
+ const acquired = await claim(initial.revision)
189
+ await expect(
190
+ runTestEffect(
191
+ ClaimService.pipe(
192
+ Effect.flatMap((service) =>
193
+ service.finish(
194
+ {
195
+ taskId: "single",
196
+ sessionId: "session-1",
197
+ revision: acquired.revision,
198
+ outcome: "done",
199
+ nonPrCompletion: { summary: " " },
200
+ },
201
+ root,
202
+ ),
203
+ ),
204
+ ),
205
+ ),
206
+ ).rejects.toThrow("summary must not be empty")
207
+ })
208
+
150
209
  test("returns structured ownership and revision conflicts", async () => {
151
210
  const initial = await inspect()
152
211
  const acquired = await claim(initial.revision)
@@ -31,6 +31,10 @@ import {
31
31
  type PhaseFrontmatter as PhaseData,
32
32
  type TaskFrontmatter as TaskData,
33
33
  } from "../workbase/schemas"
34
+ import {
35
+ buildNonPrCompletion,
36
+ type NonPrCompletionInput,
37
+ } from "../workbase/completion"
34
38
 
35
39
  class ClaimError extends Data.TaggedError("ClaimError")<{
36
40
  readonly message: string
@@ -83,6 +87,7 @@ interface OwnedClaimInput {
83
87
 
84
88
  interface FinishInput extends OwnedClaimInput {
85
89
  readonly outcome: "done" | "dropped"
90
+ readonly nonPrCompletion?: NonPrCompletionInput
86
91
  }
87
92
 
88
93
  interface ExpireClaimInput {
@@ -507,6 +512,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
507
512
  finish: (input: FinishInput, startPath: string = process.cwd()) =>
508
513
  Effect.gen(function* () {
509
514
  assertIdentity("Session ID", input.sessionId)
515
+ if (input.nonPrCompletion && input.outcome !== "done") {
516
+ return yield* new ClaimError({
517
+ message: "Non-PR completion is valid only with a done outcome",
518
+ })
519
+ }
510
520
  const service = yield* ClaimService
511
521
  const inspected = yield* service.inspect(
512
522
  input.taskId,
@@ -531,6 +541,22 @@ export class ClaimService extends Effect.Service<ClaimService>()(
531
541
  message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
532
542
  })
533
543
  }
544
+ if (input.nonPrCompletion && data.pr !== null) {
545
+ throw new ClaimError({
546
+ target: inspected.target.label,
547
+ message:
548
+ "Cannot complete without a pull request while an authoritative pull request is recorded",
549
+ })
550
+ }
551
+ const completionResult = input.nonPrCompletion
552
+ ? buildNonPrCompletion(input.nonPrCompletion, now)
553
+ : undefined
554
+ if (completionResult && "error" in completionResult) {
555
+ throw new ClaimError({
556
+ target: inspected.target.label,
557
+ message: completionResult.error,
558
+ })
559
+ }
534
560
  const claim: ClaimRecord = {
535
561
  ...data.claim,
536
562
  state: "finished",
@@ -540,8 +566,15 @@ export class ClaimService extends Effect.Service<ClaimService>()(
540
566
  return {
541
567
  data: {
542
568
  ...data,
543
- status: input.outcome === "done" ? "working" : "dropped",
569
+ status: completionResult
570
+ ? "done"
571
+ : input.outcome === "done"
572
+ ? "working"
573
+ : "dropped",
544
574
  claim,
575
+ ...(completionResult
576
+ ? { completion: completionResult.value }
577
+ : {}),
545
578
  },
546
579
  claim,
547
580
  }