@vercel/factory 0.0.12 → 0.0.14

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/dist/api.d.mts +225 -0
  3. package/dist/api.mjs +312 -0
  4. package/dist/api.mjs.map +1 -0
  5. package/dist/{approval-DdvEKLiE.mjs → approval-JX2Px9IC.mjs} +2 -2
  6. package/dist/{approval-DdvEKLiE.mjs.map → approval-JX2Px9IC.mjs.map} +1 -1
  7. package/dist/blob/index.d.mts +4 -3
  8. package/dist/blob/index.mjs +21 -15
  9. package/dist/blob/index.mjs.map +1 -1
  10. package/dist/budget-DzSiIA4t.mjs +42 -0
  11. package/dist/budget-DzSiIA4t.mjs.map +1 -0
  12. package/dist/build-factory.d.mts +1 -1
  13. package/dist/build-factory.mjs +1 -1
  14. package/dist/change-D2U6v7Ek.mjs +554 -0
  15. package/dist/change-D2U6v7Ek.mjs.map +1 -0
  16. package/dist/communication-Kd9nU5RP.mjs +30 -0
  17. package/dist/communication-Kd9nU5RP.mjs.map +1 -0
  18. package/dist/connectors/index.d.mts +3 -2
  19. package/dist/connectors/index.mjs +53 -26
  20. package/dist/connectors/index.mjs.map +1 -1
  21. package/dist/connectors/slack.d.mts +17 -6
  22. package/dist/connectors/slack.mjs +99 -32
  23. package/dist/connectors/slack.mjs.map +1 -1
  24. package/dist/coordination-Cq08hb1l.mjs +76 -0
  25. package/dist/coordination-Cq08hb1l.mjs.map +1 -0
  26. package/dist/dispatch-BaZS3PT8.d.mts +76 -0
  27. package/dist/{driver-CrZsy7sm.d.mts → driver-BC0Mt5KG.d.mts} +2 -2
  28. package/dist/driver-Bbd25rNM.mjs.map +1 -1
  29. package/dist/engine-DFHU2Pe-.d.mts +781 -0
  30. package/dist/engine-WaUM2cGA.mjs +1068 -0
  31. package/dist/engine-WaUM2cGA.mjs.map +1 -0
  32. package/dist/eve/index.d.mts +289 -153
  33. package/dist/eve/index.mjs +378 -89
  34. package/dist/eve/index.mjs.map +1 -1
  35. package/dist/{factory-config-ifd3JxiU.mjs → factory-config-BoRXOw43.mjs} +23 -22
  36. package/dist/factory-config-BoRXOw43.mjs.map +1 -0
  37. package/dist/{factory-config-Bu4mklm9.d.mts → factory-config-D2yU3G0c.d.mts} +21 -2
  38. package/dist/{github-wThBfbm-.d.mts → github-C5HNBN6p.d.mts} +34 -3
  39. package/dist/github.d.mts +2 -2
  40. package/dist/github.mjs +61 -1
  41. package/dist/github.mjs.map +1 -1
  42. package/dist/{id-DwCihQr4.mjs → id-BLIkCVue.mjs} +4 -2
  43. package/dist/{id-DwCihQr4.mjs.map → id-BLIkCVue.mjs.map} +1 -1
  44. package/dist/index-Bw6hggoH.d.mts +40 -0
  45. package/dist/index.d.mts +291 -61
  46. package/dist/index.mjs +140 -783
  47. package/dist/index.mjs.map +1 -1
  48. package/dist/postgres/index.d.mts +1 -1
  49. package/dist/task-CcOyOJ3k.mjs +154 -0
  50. package/dist/task-CcOyOJ3k.mjs.map +1 -0
  51. package/package.json +5 -1
  52. package/dist/approval-CIXB8IF8.d.mts +0 -522
  53. package/dist/change-CFNGXVqD.mjs +0 -230
  54. package/dist/change-CFNGXVqD.mjs.map +0 -1
  55. package/dist/factory-config-ifd3JxiU.mjs.map +0 -1
  56. package/dist/index-Cy4jfXh5.d.mts +0 -67
  57. package/dist/signal-CHVEmzvI.d.mts +0 -59
@@ -1,8 +1,11 @@
1
- import { i as repositoryIdSchema, o as signalIdSchema, p as vocabularySchema, s as taskIdSchema, t as changeIdSchema } from "../id-DwCihQr4.mjs";
2
- import { c as findingSchema, i as requiresApproval, o as findingFingerprint, r as defaultApprovalPolicy } from "../approval-DdvEKLiE.mjs";
3
- import { g as taskQuestionSchema, i as levelSchema, s as dispatchTask, t as changeEvidenceSchema } from "../change-CFNGXVqD.mjs";
1
+ import { c as signalIdSchema, h as vocabularySchema, l as taskIdSchema, n as changeIdSchema, o as repositoryIdSchema } from "../id-BLIkCVue.mjs";
2
+ import { l as taskQuestionSchema } from "../task-CcOyOJ3k.mjs";
3
+ import { c as findingSchema, i as requiresApproval, o as findingFingerprint, r as defaultApprovalPolicy } from "../approval-JX2Px9IC.mjs";
4
+ import { c as dispatchTask, i as levelSchema, t as changeEvidenceSchema } from "../change-D2U6v7Ek.mjs";
5
+ import { t as createCoordination } from "../coordination-Cq08hb1l.mjs";
4
6
  import { answerReviewComment, readPullRequestHead, readReviewComment, submitPullRequestReview } from "../github.mjs";
5
7
  import { z } from "zod";
8
+ import { createHash } from "node:crypto";
6
9
  import { defineHook } from "eve/hooks";
7
10
  import { defineTool } from "eve/tools";
8
11
  //#region src/eve/conflict.ts
@@ -369,9 +372,21 @@ function fetchPullRequestTool(stores, options) {
369
372
  const repoPath = options.repoPath ?? "repo";
370
373
  const depth = options.depth ?? 50;
371
374
  return defineTool({
372
- description: "Fetch the pull request under review into the checkout and check it out as the local branch `pr`. Call this after get_task, before inspecting or testing the change. Returns the PR number, URL, and the commit you are now on.",
373
- inputSchema: z.object({ changeId: changeIdSchema.describe("The change under review, from your starting message.") }),
375
+ description: "Fetch the pull request and the repository's current default branch, then check out the pull request as local branch `pr`. Call this after get_task, before inspecting or testing the change. Returns an exact diff command based on their merge base; use it instead of origin/HEAD.",
376
+ inputSchema: z.object({ changeId: changeIdSchema.describe("The Change linked to your Task, returned by get_task.") }),
374
377
  async execute({ changeId }, ctx) {
378
+ const boundTaskId = taskIdSchema.safeParse(ctx.session?.auth.initiator?.attributes.factoryTaskId);
379
+ if (boundTaskId.success) {
380
+ const task = await stores.tasks.get(boundTaskId.data);
381
+ if (task === null) return {
382
+ fetched: false,
383
+ reason: `task ${boundTaskId.data} does not exist`
384
+ };
385
+ if (task.changeId !== void 0 && task.changeId !== changeId) return {
386
+ fetched: false,
387
+ reason: `change ${changeId} is not linked to task ${boundTaskId.data}`
388
+ };
389
+ }
375
390
  const change = await stores.changes.get(changeId);
376
391
  if (change === null) return {
377
392
  fetched: false,
@@ -388,19 +403,31 @@ function fetchPullRequestTool(stores, options) {
388
403
  };
389
404
  const sandbox = await ctx.getSandbox();
390
405
  const tracking = `refs/remotes/origin/pr-${change.prNumber}`;
406
+ const baseTracking = `refs/remotes/origin/${repository.defaultBranch}`;
391
407
  const git = `git -C ${repoPath}`;
392
- const result = await options.withCredential(sandbox, repository.slug, `${git} fetch --depth ${depth} origin +refs/pull/${change.prNumber}/head:${tracking} && ${git} checkout -B pr ${tracking}`);
408
+ const result = await options.withCredential(sandbox, repository.slug, `${git} fetch --depth ${depth} origin +refs/heads/${repository.defaultBranch}:${baseTracking} +refs/pull/${change.prNumber}/head:${tracking} && ${git} checkout -B pr ${tracking}`);
393
409
  if (typeof result.exitCode === "number" && result.exitCode !== 0) return {
394
410
  fetched: false,
395
411
  reason: `git fetch of pull request ${change.prNumber} failed: ${(result.stderr ?? "").trim()}`
396
412
  };
397
413
  const head = await sandbox.run({ command: `${git} rev-parse HEAD` });
414
+ const base = await sandbox.run({ command: `${git} rev-parse ${baseTracking}` });
415
+ const mergeBase = await sandbox.run({ command: `${git} merge-base ${baseTracking} pr` });
416
+ if (typeof mergeBase.exitCode === "number" && mergeBase.exitCode !== 0) return {
417
+ fetched: false,
418
+ reason: `could not determine the pull request merge base: ${(mergeBase.stderr ?? "").trim()}`
419
+ };
420
+ const mergeBaseSha = mergeBase.stdout.trim();
398
421
  return {
399
422
  fetched: true,
400
423
  prNumber: change.prNumber,
401
424
  prUrl: change.prUrl,
402
425
  branch: "pr",
403
- headSha: head.stdout.trim()
426
+ headSha: head.stdout.trim(),
427
+ baseBranch: repository.defaultBranch,
428
+ baseSha: base.stdout.trim(),
429
+ mergeBaseSha,
430
+ diffCommand: `${git} diff ${mergeBaseSha}..pr`
404
431
  };
405
432
  }
406
433
  });
@@ -439,6 +466,7 @@ function invokeAgentTool(stores, options) {
439
466
  });
440
467
  const running = await dispatchTask(stores, task.id, {
441
468
  maxLaunchAttempts: 1,
469
+ budget: options.budget?.(),
442
470
  launch: async (dispatched) => {
443
471
  const headers = {
444
472
  "content-type": "application/json",
@@ -474,20 +502,156 @@ function invokeAgentTool(stores, options) {
474
502
  }
475
503
  });
476
504
  }
505
+ /**
506
+ * Give a conversational agent one bounded delegation surface backed by the
507
+ * application's route registry. The caller selects a declared route; trusted
508
+ * code owns task creation, budget enforcement, and the fresh target session.
509
+ */
510
+ function delegateAgentTool(stores, options) {
511
+ const doFetch = options.fetch ?? fetch;
512
+ const routeSummary = options.routes.routes.map((route) => `${route.id}: ${route.description}`).join("\n");
513
+ return defineTool({
514
+ description: "Delegate durable work to one registered specialist. Select only a listed route; the specialist runs independently and reports its result through Factory.\n" + routeSummary,
515
+ inputSchema: z.object({
516
+ routeId: z.string().min(1).describe("One route id listed in this tool's description."),
517
+ repositoryIds: z.array(repositoryIdSchema).min(1),
518
+ signalId: signalIdSchema.optional().describe("Pending external signal this delegation accepts."),
519
+ parentTaskId: taskIdSchema.optional().describe("Existing task this work continues."),
520
+ changeId: changeIdSchema.optional().describe("Existing change the specialist should inspect."),
521
+ channel: z.string().min(1).describe("Originating conversation channel."),
522
+ address: z.string().min(1).describe("Originating conversation reply address."),
523
+ brief: z.string().min(1).max(4e3).describe("Facts and desired outcome, without hidden reasoning.")
524
+ }),
525
+ async execute(input, ctx) {
526
+ const route = options.routes.get(input.routeId);
527
+ if (route === void 0) throw new Error(`Unknown agent route ${input.routeId}.`);
528
+ const replyTo = {
529
+ channel: input.channel,
530
+ address: input.address
531
+ };
532
+ const attributes = ctx.session.auth.initiator?.attributes;
533
+ const repositoryAttribute = attributes?.factoryRepositoryIds;
534
+ const boundRepositoryIds = typeof repositoryAttribute === "string" ? repositoryAttribute.split(",").filter(Boolean) : repositoryAttribute;
535
+ if (boundRepositoryIds !== void 0 && (boundRepositoryIds.length !== input.repositoryIds.length || boundRepositoryIds.some((id) => !input.repositoryIds.includes(id)))) throw new Error("Delegation repositories do not match the connector-authenticated context.");
536
+ if (attributes?.factoryReplyChannel !== void 0 && attributes.factoryReplyChannel !== replyTo.channel || attributes?.factoryReplyAddress !== void 0 && attributes.factoryReplyAddress !== replyTo.address) throw new Error("Delegation reply address does not match the authenticated conversation.");
537
+ for (const repositoryId of input.repositoryIds) {
538
+ const repository = await stores.repositories.get(repositoryId);
539
+ if (repository === null || !repository.enabled) throw new Error(`Repository ${repositoryId} is not enabled in this factory.`);
540
+ }
541
+ const boundChange = changeIdSchema.safeParse(attributes?.factoryChangeId);
542
+ if (attributes?.factoryChangeId !== void 0 && !boundChange.success) throw new Error("Connector-authenticated change context is invalid.");
543
+ if (input.changeId !== void 0 && boundChange.success && input.changeId !== boundChange.data) throw new Error("Delegated change does not match the connector-authenticated context.");
544
+ const changeId = input.changeId ?? (boundChange.success ? boundChange.data : void 0);
545
+ if (changeId !== void 0) {
546
+ if (input.repositoryIds.length !== 1) throw new Error("Delegating work on an existing Change requires exactly one repository.");
547
+ const change = await stores.changes.get(changeId);
548
+ if (change === null || change.repositoryId !== input.repositoryIds[0] || change.state !== "verifying" && change.state !== "ready") throw new Error(`Change ${changeId} is not an open pull request in the selected repository.`);
549
+ }
550
+ if (input.signalId !== void 0) {
551
+ const signal = await stores.signals.get(input.signalId);
552
+ if (signal === null) throw new Error(`Signal ${input.signalId} does not exist.`);
553
+ if (!input.repositoryIds.includes(signal.repositoryId) || signal.replyTo.channel !== replyTo.channel || signal.replyTo.address !== replyTo.address) throw new Error("Delegation does not match the signal's authenticated repository and reply address.");
554
+ if (signal.outcome === "pending") await stores.signals.decide(signal.id, "accepted", { reason: `Foreman selected ${route.id}.` });
555
+ else if (signal.outcome !== "accepted") throw new Error(`Signal ${signal.id} is already ${signal.outcome}.`);
556
+ }
557
+ const operator = ctx.session.auth.initiator?.principalId ?? "foreman";
558
+ if (route.taskKind === "code_change" && input.repositoryIds.length > 1) {
559
+ const [first, second, ...rest] = input.repositoryIds;
560
+ if (first === void 0 || second === void 0) throw new Error("Coordination requires two repositories.");
561
+ const coordination = await createCoordination(stores, {
562
+ repositoryIds: [
563
+ first,
564
+ second,
565
+ ...rest
566
+ ],
567
+ replyTo,
568
+ ...input.signalId === void 0 ? {} : { dedupeKey: `route:${input.signalId}:${route.id}` },
569
+ ...input.signalId === void 0 ? { operator } : { signalId: input.signalId }
570
+ });
571
+ await Promise.all(coordination.children.filter((child) => child.state === "queued").map((child) => dispatchRouteTask(stores, options, doFetch, route, child, changeId, input.brief)));
572
+ return {
573
+ taskId: coordination.parent.id,
574
+ childTaskIds: coordination.children.map(({ id }) => id),
575
+ routeId: route.id,
576
+ agent: route.agent,
577
+ state: coordination.parent.state
578
+ };
579
+ }
580
+ const task = await stores.tasks.create({
581
+ repositoryIds: input.repositoryIds,
582
+ kind: route.taskKind,
583
+ origin: input.signalId !== void 0 ? { signalId: input.signalId } : input.parentTaskId !== void 0 ? { parentTaskId: input.parentTaskId } : { operator },
584
+ replyTo,
585
+ changeId,
586
+ ...input.signalId === void 0 ? {} : { dedupeKey: `route:${input.signalId}:${route.id}` }
587
+ });
588
+ const running = task.state === "queued" ? await dispatchRouteTask(stores, options, doFetch, route, task, changeId, input.brief) : task;
589
+ return {
590
+ taskId: running.id,
591
+ routeId: route.id,
592
+ agent: route.agent,
593
+ state: running.state
594
+ };
595
+ }
596
+ });
597
+ }
598
+ async function dispatchRouteTask(stores, options, doFetch, route, task, changeId, brief) {
599
+ return dispatchTask(stores, task.id, {
600
+ maxLaunchAttempts: 1,
601
+ budget: options.budget?.(route),
602
+ launch: async (dispatched) => {
603
+ const headers = {
604
+ "content-type": "application/json",
605
+ ...await options.headers?.(dispatched, route)
606
+ };
607
+ const token = await options.token?.(route);
608
+ if (token !== void 0) headers.authorization = `Bearer ${token}`;
609
+ const message = [
610
+ `You are assigned factory task ${dispatched.id} through route ${route.id}.`,
611
+ changeId === void 0 ? null : `Change under review: ${changeId}.`,
612
+ brief,
613
+ "Read the task first and return exactly one bounded Factory outcome."
614
+ ].filter((line) => line !== null).join("\n\n");
615
+ const response = await doFetch(`${options.targetUrl(route)}/eve/v1/session`, {
616
+ method: "POST",
617
+ headers,
618
+ body: JSON.stringify({
619
+ message,
620
+ operationId: `factory-task:${dispatched.id}`
621
+ })
622
+ });
623
+ if (!response.ok) throw new Error(`${route.agent} refused the session: ${response.status}`);
624
+ }
625
+ });
626
+ }
477
627
  //#endregion
478
628
  //#region src/eve/post-review.ts
479
629
  const reviewCommentSchema = z.strictObject({
630
+ key: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional().describe("Stable kebab-case identity for this defect and cause; keep it unchanged across reruns."),
631
+ title: z.string().min(1).max(200).optional().describe("Short finding title."),
480
632
  path: z.string().min(1).max(500).describe("Repository-relative file path."),
481
633
  line: z.number().int().min(1).describe("Line in the pull request's new version."),
482
634
  body: z.string().min(1).max(5e3).describe("Markdown explaining the finding and a concrete fix.")
483
635
  });
636
+ const reviewCommentsSchema = z.array(reviewCommentSchema).max(50).default([]).superRefine((comments, context) => {
637
+ const seen = /* @__PURE__ */ new Set();
638
+ comments.forEach((comment, index) => {
639
+ if (comment.key === void 0) return;
640
+ if (seen.has(comment.key)) context.addIssue({
641
+ code: "custom",
642
+ message: `duplicate finding key: ${comment.key}`,
643
+ path: [index, "key"]
644
+ });
645
+ seen.add(comment.key);
646
+ });
647
+ });
484
648
  /**
485
649
  * Publish an independent review of an existing pull request and finish the
486
650
  * review task without changing the pull request's factory lifecycle.
487
651
  */
488
652
  function postReviewTool(stores, options) {
489
653
  return defineTool({
490
- description: "Publish a completed code review to the existing pull request and finish your code_review task. Use request_changes only for blocking findings.",
654
+ description: "Record a completed code review and finish your code_review task. Results may be reported through completion callbacks instead of a GitHub review; use request_changes only for blocking findings.",
491
655
  inputSchema: z.object({
492
656
  taskId: taskIdSchema.describe("Your code_review task."),
493
657
  changeId: changeIdSchema.describe("The pull request change from your starting message."),
@@ -498,7 +662,7 @@ function postReviewTool(stores, options) {
498
662
  ]),
499
663
  headSha: z.string().regex(/^[0-9a-f]{40}$/).describe("Commit returned by fetch_pull_request."),
500
664
  summary: z.string().min(1).max(5e3).describe("Markdown summary of what you reviewed and found."),
501
- comments: z.array(reviewCommentSchema).max(50).default([])
665
+ comments: reviewCommentsSchema
502
666
  }),
503
667
  async execute({ taskId, changeId, verdict, headSha, summary, comments }) {
504
668
  const task = await stores.tasks.get(taskId);
@@ -524,36 +688,68 @@ function postReviewTool(stores, options) {
524
688
  recorded: false,
525
689
  reason: `repository ${change.repositoryId} does not exist`
526
690
  };
527
- const review = await submitPullRequestReview({
528
- repository: repository.slug,
529
- token: await options.tokenForRepository(repository.slug),
530
- fetch: options.fetch,
531
- pullRequestNumber: change.prNumber,
532
- commitId: headSha,
533
- event: verdict === "approve" ? "APPROVE" : verdict === "request_changes" ? "REQUEST_CHANGES" : "COMMENT",
534
- body: summary,
535
- comments
691
+ const token = await options.tokenForRepository(repository.slug);
692
+ const completedComments = comments.map((comment) => {
693
+ const key = comment.key ?? legacyFindingKey(comment);
694
+ return {
695
+ ...comment,
696
+ key,
697
+ title: comment.title ?? "Review finding",
698
+ fingerprint: createHash("sha256").update(`${repository.slug.toLowerCase()}#${change.prNumber}:${key}`).digest("hex")
699
+ };
536
700
  });
537
- await options.onPublished?.({
701
+ const completed = {
538
702
  taskId,
539
703
  changeId,
540
704
  repository: repository.slug,
705
+ pullRequestNumber: change.prNumber,
541
706
  headSha,
542
707
  verdict,
543
708
  summary,
544
- comments,
545
- review
546
- });
709
+ comments: completedComments
710
+ };
711
+ const publish = options.publishReviews !== false;
712
+ let review;
713
+ if (publish) {
714
+ review = await submitPullRequestReview({
715
+ repository: repository.slug,
716
+ token,
717
+ fetch: options.fetch,
718
+ pullRequestNumber: change.prNumber,
719
+ commitId: headSha,
720
+ event: verdict === "approve" ? "APPROVE" : verdict === "request_changes" ? "REQUEST_CHANGES" : "COMMENT",
721
+ body: summary,
722
+ comments: completedComments
723
+ });
724
+ await options.onPublished?.({
725
+ ...completed,
726
+ review
727
+ });
728
+ } else {
729
+ const head = await readPullRequestHead({
730
+ repository: repository.slug,
731
+ token,
732
+ fetch: options.fetch,
733
+ pullRequestNumber: change.prNumber
734
+ });
735
+ if (head.sha !== headSha) throw new Error(`pull request changed after review: expected ${headSha}, found ${head.sha}`);
736
+ }
737
+ await options.onCompleted?.(completed);
547
738
  await stores.tasks.transition(taskId, "verifying", { reason: summary });
739
+ const finished = await stores.tasks.transition(taskId, "succeeded");
548
740
  return {
549
741
  recorded: true,
550
742
  verdict,
551
- review,
552
- taskState: (await stores.tasks.transition(taskId, "succeeded")).state
743
+ published: review !== void 0,
744
+ ...review === void 0 ? {} : { review },
745
+ taskState: finished.state
553
746
  };
554
747
  }
555
748
  });
556
749
  }
750
+ function legacyFindingKey(comment) {
751
+ return `legacy-${createHash("sha256").update(`${comment.path}:${comment.line}:${comment.body}`).digest("hex").slice(0, 24)}`;
752
+ }
557
753
  //#endregion
558
754
  //#region src/eve/review.ts
559
755
  function reviewCommentTools(stores, options) {
@@ -686,7 +882,7 @@ function reviewCommentTools(stores, options) {
686
882
  * as still in progress. Fail closed: the agent cannot mark a change ready
687
883
  * without supplying every evidence field.
688
884
  */
689
- function recordVerificationTool(stores) {
885
+ function recordVerificationTool(stores, options = {}) {
690
886
  return defineTool({
691
887
  description: "Record the outcome of a verification task: the evidence you gathered and whether the change passed. Pass moves the change to ready for human review; fail keeps it out of review.",
692
888
  inputSchema: z.object({
@@ -703,9 +899,9 @@ function recordVerificationTool(stores) {
703
899
  recorded: false,
704
900
  reason: `change ${changeId} does not exist`
705
901
  };
706
- if (change.state !== "verifying") return {
902
+ if (change.state !== "verifying" && change.state !== "ready") return {
707
903
  recorded: false,
708
- reason: `change is ${change.state}, not verifying; nothing to record`
904
+ reason: `change is ${change.state}, not open for verification; nothing to record`
709
905
  };
710
906
  const task = await stores.tasks.get(taskId);
711
907
  if (task === null || task.kind !== "verification") return {
@@ -713,21 +909,29 @@ function recordVerificationTool(stores) {
713
909
  reason: `${taskId} is not a verification task`
714
910
  };
715
911
  let updatedChange = change;
716
- if (verdict === "pass") updatedChange = await stores.changes.transition(changeId, "ready", {
912
+ if (verdict === "pass" && change.state === "verifying") updatedChange = await stores.changes.transition(changeId, "ready", {
717
913
  patch: {
718
914
  evidence,
719
915
  confidence
720
916
  },
721
917
  reason: summary
722
918
  });
919
+ else if (verdict === "fail" && change.state === "ready") updatedChange = await stores.changes.transition(changeId, "verifying", { reason: summary });
723
920
  let finished;
724
921
  if (verdict === "pass") {
725
922
  await stores.tasks.transition(taskId, "verifying", { reason: summary });
726
923
  finished = await stores.tasks.transition(taskId, "succeeded");
727
924
  } else finished = await stores.tasks.transition(taskId, "failed", { reason: summary });
728
- if (change.taskId !== void 0) {
729
- if ((await stores.tasks.get(change.taskId))?.state === "verifying") await stores.tasks.transition(change.taskId, verdict === "pass" ? "succeeded" : "failed", verdict === "pass" ? {} : { reason: `verification failed: ${summary}` }).catch(() => void 0);
925
+ const sourceTaskId = task.origin.parentTaskId ?? change.taskId;
926
+ if (sourceTaskId !== void 0) {
927
+ if ((await stores.tasks.get(sourceTaskId))?.state === "verifying") await stores.tasks.transition(sourceTaskId, verdict === "pass" ? "succeeded" : "failed", verdict === "pass" ? {} : { reason: `verification failed: ${summary}` }).catch(() => void 0);
730
928
  }
929
+ await options.presentResult?.({
930
+ taskId,
931
+ changeId,
932
+ verdict,
933
+ summary
934
+ });
731
935
  return {
732
936
  recorded: true,
733
937
  verdict,
@@ -755,6 +959,28 @@ const recordChangeInputSchema = z.object({
755
959
  confidence: levelSchema.describe("How certain you are the change is correct."),
756
960
  summary: z.string().min(1).describe("One-sentence description of the change.")
757
961
  });
962
+ async function createAndPresentQuestion(stores, parent, question, presentQuestion) {
963
+ const task = await stores.tasks.create({
964
+ repositoryIds: parent.repositoryIds,
965
+ kind: "question",
966
+ origin: { parentTaskId: parent.id },
967
+ replyTo: parent.replyTo,
968
+ question
969
+ });
970
+ const waiting = await stores.tasks.transition(task.id, "needs_human");
971
+ try {
972
+ if (parent.state === "running") await stores.tasks.transition(parent.id, "needs_human", {
973
+ reason: `waiting for answer to question ${waiting.id}`,
974
+ questionTaskId: waiting.id
975
+ });
976
+ await presentQuestion(waiting);
977
+ } catch (error) {
978
+ if ((await stores.tasks.get(parent.id))?.state === "needs_human") await stores.tasks.transition(parent.id, "queued", { reason: `delivery of question ${waiting.id} failed` });
979
+ if ((await stores.tasks.get(waiting.id))?.state === "needs_human") await stores.tasks.transition(waiting.id, "cancelled", { reason: "question delivery failed" });
980
+ throw error;
981
+ }
982
+ return waiting;
983
+ }
758
984
  /**
759
985
  * The factory's store operations wrapped as eve tools. Expose a tool to an
760
986
  * agent by re-exporting it from a file under agent/tools/, e.g.
@@ -763,6 +989,7 @@ const recordChangeInputSchema = z.object({
763
989
  */
764
990
  function factoryTools(stores, options = {}) {
765
991
  const recordChangeGate = options.recordChangeGate;
992
+ const verification = options.verification ?? "required";
766
993
  return {
767
994
  get_task: defineTool({
768
995
  description: "Read one factory task by id: its kind, state, repositories, origin, and results.",
@@ -790,7 +1017,7 @@ function factoryTools(stores, options = {}) {
790
1017
  }
791
1018
  }),
792
1019
  record_change: defineTool({
793
- description: "Record the pull request produced for a code_change task. Creates the change, links it to the task, and moves the task to verifying.",
1020
+ description: verification === "required" ? "Record the pull request produced for a code_change task. Creates the change, links it to the task, and moves the task to verifying." : "Record the pull request produced for a code_change task. Creates the change and marks it ready for human review without independent verification.",
794
1021
  inputSchema: recordChangeInputSchema,
795
1022
  ...recordChangeGate === void 0 ? {} : { approval: async ({ toolInput }) => {
796
1023
  const parsed = recordChangeInputSchema.safeParse(toolInput);
@@ -805,28 +1032,51 @@ function factoryTools(stores, options = {}) {
805
1032
  };
806
1033
  } },
807
1034
  async execute({ taskId, repositoryId, prNumber, prUrl, risk, confidence, summary }) {
808
- const change = await stores.changes.create({
809
- repositoryId,
1035
+ const currentTask = await stores.tasks.get(taskId);
1036
+ if (currentTask === null) throw new Error(`task ${taskId} does not exist`);
1037
+ if (currentTask.state !== "running" && currentTask.state !== "verifying" && currentTask.state !== "succeeded") throw new Error(`task ${taskId} is ${currentTask.state}, not running`);
1038
+ const linkedChange = currentTask.changeId === void 0 ? void 0 : await stores.changes.get(currentTask.changeId);
1039
+ if (currentTask.changeId !== void 0 && linkedChange === null) throw new Error(`change ${currentTask.changeId} does not exist`);
1040
+ let change = linkedChange ?? (await stores.changes.list()).find((candidate) => candidate.taskId === taskId);
1041
+ if (change === void 0) {
1042
+ if (currentTask.state !== "running") throw new Error(`task ${taskId} has no recorded change`);
1043
+ change = await stores.changes.create({
1044
+ repositoryId,
1045
+ taskId,
1046
+ prNumber,
1047
+ prUrl,
1048
+ author: "factory",
1049
+ risk,
1050
+ confidence,
1051
+ evidence: {
1052
+ reproVerified: false,
1053
+ testsAdded: 0,
1054
+ checksGreen: false,
1055
+ attackPassed: false,
1056
+ scopeMatches: false
1057
+ }
1058
+ });
1059
+ }
1060
+ if (currentTask.state === "running") await options.presentChange?.({
810
1061
  taskId,
811
- prNumber,
812
- prUrl,
813
- author: "factory",
814
- risk,
815
- confidence,
816
- evidence: {
817
- reproVerified: false,
818
- testsAdded: 0,
819
- checksGreen: false,
820
- attackPassed: false,
821
- scopeMatches: false
822
- }
1062
+ changeId: change.id,
1063
+ verification,
1064
+ summary
823
1065
  });
1066
+ let task = currentTask;
1067
+ if (task.state === "running") task = await stores.tasks.transition(taskId, "verifying", {
1068
+ patch: { changeId: change.id },
1069
+ reason: summary
1070
+ });
1071
+ if (verification === "required") return {
1072
+ change,
1073
+ task
1074
+ };
1075
+ if (change.state === "verifying") change = await stores.changes.transition(change.id, "ready", { reason: "independent verification is not configured" });
1076
+ if (task.state === "verifying") task = await stores.tasks.transition(taskId, "succeeded", { reason: "change recorded for human review without independent verification" });
824
1077
  return {
825
1078
  change,
826
- task: await stores.tasks.transition(taskId, "verifying", {
827
- patch: { changeId: change.id },
828
- reason: summary
829
- })
1079
+ task
830
1080
  };
831
1081
  }
832
1082
  }),
@@ -857,40 +1107,42 @@ function factoryTools(stores, options = {}) {
857
1107
  };
858
1108
  }
859
1109
  }),
860
- report_blocked: defineTool({
861
- description: "Mark a running task as needing a human because the work cannot continue without help.",
1110
+ request_human_input: defineTool({
1111
+ description: "Request human input or action and pause your task until the human replies. The parent task determines the repositories and reply destination.",
862
1112
  inputSchema: z.object({
863
- taskId: taskIdSchema,
864
- reason: z.string().min(1).describe("What is blocking, written for the human.")
865
- }),
866
- execute({ taskId, reason }) {
867
- return stores.tasks.transition(taskId, "needs_human", { reason });
868
- }
869
- }),
870
- ask_question: defineTool({
871
- description: "Raise a question a human operator must answer. Creates a question task that waits for the answer.",
872
- inputSchema: z.object({
873
- repositoryIds: z.array(repositoryIdSchema).min(1),
874
- parentTaskId: taskIdSchema.optional().describe("Task the answer unblocks, when there is one."),
875
- channel: z.string().min(1).describe("Reply connector, e.g. github."),
876
- address: z.string().min(1).describe("Reply address, e.g. acme/web#41."),
1113
+ parentTaskId: taskIdSchema.describe("Your running task, which the answer will unblock."),
877
1114
  question: taskQuestionSchema.omit({
878
1115
  answer: true,
879
1116
  answeredBy: true
880
1117
  })
881
1118
  }),
882
- async execute({ repositoryIds, parentTaskId, channel, address, question }) {
883
- const task = await stores.tasks.create({
884
- repositoryIds,
885
- kind: "question",
886
- origin: parentTaskId === void 0 ? { operator: "factory" } : { parentTaskId },
887
- replyTo: {
888
- channel,
889
- address
890
- },
891
- question
1119
+ async execute({ parentTaskId, question }) {
1120
+ if (options.presentQuestion === void 0) throw new Error("request_human_input requires a configured question presenter.");
1121
+ const parent = await stores.tasks.get(parentTaskId);
1122
+ if (parent === null) throw new Error(`Parent task ${parentTaskId} does not exist.`);
1123
+ if (parent.state !== "running" && parent.state !== "needs_human") throw new Error(`Parent task ${parentTaskId} is ${parent.state}, not running.`);
1124
+ return createAndPresentQuestion(stores, parent, question, options.presentQuestion);
1125
+ }
1126
+ }),
1127
+ answer_question: defineTool({
1128
+ description: "Record the authenticated user's answer to a waiting question and requeue the parent specialist task. A blocked parent task id is also accepted and resolves its persisted QuestionTask id.",
1129
+ inputSchema: z.object({
1130
+ questionTaskId: taskIdSchema,
1131
+ answer: z.string().min(1).max(4e3)
1132
+ }),
1133
+ async execute({ questionTaskId, answer }, ctx) {
1134
+ const requestedTask = await stores.tasks.get(questionTaskId);
1135
+ const resolvedQuestionTaskId = requestedTask?.kind === "question" ? requestedTask.id : requestedTask?.questionTaskId;
1136
+ const questionTask = resolvedQuestionTaskId === void 0 ? null : await stores.tasks.get(resolvedQuestionTaskId);
1137
+ if (questionTask === null || questionTask.kind !== "question" || questionTask.question === void 0) throw new Error(`Question task ${questionTaskId} does not exist.`);
1138
+ const attributes = ctx.session.auth.initiator?.attributes;
1139
+ if (attributes?.factoryReplyChannel !== questionTask.replyTo.channel || attributes.factoryReplyAddress !== questionTask.replyTo.address) throw new Error("Question reply does not match the authenticated conversation.");
1140
+ const answeredBy = ctx.session.auth.initiator?.principalId ?? "operator";
1141
+ return stores.tasks.answerQuestion({
1142
+ questionTaskId: questionTask.id,
1143
+ answer,
1144
+ answeredBy
892
1145
  });
893
- return stores.tasks.transition(task.id, "needs_human");
894
1146
  }
895
1147
  }),
896
1148
  append_receipt: defineTool({
@@ -910,25 +1162,62 @@ function factoryTools(stores, options = {}) {
910
1162
  })
911
1163
  };
912
1164
  }
1165
+ const terminalTaskStates = /* @__PURE__ */ new Set([
1166
+ "succeeded",
1167
+ "failed",
1168
+ "cancelled"
1169
+ ]);
913
1170
  /**
914
1171
  * Observe-only eve hooks that keep the ledger honest about session outcomes.
915
1172
  * Mount by re-exporting from agent/hooks/, e.g.
916
- * `export default factoryHooks(stores).factory;`.
1173
+ * `export default factoryHooks(stores, { presentQuestion }).factory;`.
1174
+ *
1175
+ * step.completed: records the step's tokens and AI Gateway cost on the bound
1176
+ * task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
917
1177
  *
918
- * turn.completed: a turn that ends while its task is still running means the
919
- * agent finished talking without recording an outcome; the task moves to
920
- * needs_human instead of rotting. The task is found by the session's
921
- * channel address, which dispatch set from the task's replyTo.
1178
+ * turn.completed: settles the bound task's recorded usage if the task is
1179
+ * already terminal (the sweep handles tasks that end later), then moves any
1180
+ * still-running task at this session's reply address behind a surfaced human
1181
+ * question, because the agent finished talking without recording an outcome.
1182
+ *
1183
+ * Every store call is swallowed: a hook throw fails the turn.
922
1184
  */
923
- function factoryHooks(stores) {
924
- return { factory: defineHook({ events: { async "turn.completed"(_event, ctx) {
925
- const address = ctx.channel.continuationToken;
926
- if (address === void 0) return;
927
- const abandoned = (await stores.tasks.list()).filter((task) => task.state === "running" && task.replyTo.address === address);
928
- for (const task of abandoned) await stores.tasks.transition(task.id, "needs_human", { reason: "agent turn ended without recording an outcome" }).catch(() => void 0);
929
- } } }) };
1185
+ function factoryHooks(stores, options) {
1186
+ const taskIdAttribute = options.taskIdAttribute ?? "factoryTaskId";
1187
+ function boundTaskId(ctx) {
1188
+ const parsed = taskIdSchema.safeParse(ctx.session.auth.initiator?.attributes[taskIdAttribute]);
1189
+ return parsed.success ? parsed.data : void 0;
1190
+ }
1191
+ async function settleIfTerminal(taskId) {
1192
+ const task = await stores.tasks.get(taskId);
1193
+ if (task?.usage === void 0 || task.settledUsd !== void 0 || !terminalTaskStates.has(task.state)) return;
1194
+ await stores.tasks.settle(task.id, { totalUsd: task.usage.costUsd }, { reason: "measured usage settled at turn end" });
1195
+ }
1196
+ return { factory: defineHook({ events: {
1197
+ async "step.completed"(event, ctx) {
1198
+ const taskId = boundTaskId(ctx);
1199
+ const usage = event.data.usage;
1200
+ if (taskId === void 0 || usage === void 0) return;
1201
+ await stores.tasks.recordUsage(taskId, {
1202
+ turnId: event.data.turnId,
1203
+ stepIndex: event.data.stepIndex,
1204
+ ...usage
1205
+ }).catch(() => void 0);
1206
+ },
1207
+ async "turn.completed"(_event, ctx) {
1208
+ const taskId = boundTaskId(ctx);
1209
+ if (taskId !== void 0) await settleIfTerminal(taskId).catch(() => void 0);
1210
+ const address = ctx.channel.continuationToken;
1211
+ if (address === void 0) return;
1212
+ const abandoned = (await stores.tasks.list().catch(() => [])).filter((task) => task.state === "running" && task.replyTo.address === address);
1213
+ for (const task of abandoned) await createAndPresentQuestion(stores, task, {
1214
+ text: "The worker stopped without recording an outcome. Please review the task and reply when Factory should retry it.",
1215
+ options: ["Retry the task"]
1216
+ }, options.presentQuestion).catch(() => void 0);
1217
+ }
1218
+ } }) };
930
1219
  }
931
1220
  //#endregion
932
- export { deepsecScanTool, factoryHooks, factoryTools, fetchPullRequestTool, findingFromDeepsec, invokeAgentTool, mergeConflictPayloadSchema, mergeConflictTools, postReviewTool, recordFinding, recordFindingTool, recordVerificationTool, reviewCommentTools };
1221
+ export { deepsecScanTool, delegateAgentTool, factoryHooks, factoryTools, fetchPullRequestTool, findingFromDeepsec, invokeAgentTool, mergeConflictPayloadSchema, mergeConflictTools, postReviewTool, recordFinding, recordFindingTool, recordVerificationTool, reviewCommentTools };
933
1222
 
934
1223
  //# sourceMappingURL=index.mjs.map