@vercel/factory 0.0.11 → 0.0.13

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 (33) hide show
  1. package/dist/approval-CwhTP1XB.mjs +269 -0
  2. package/dist/approval-CwhTP1XB.mjs.map +1 -0
  3. package/dist/blob/index.d.mts +1 -1
  4. package/dist/build-factory.d.mts +1 -1
  5. package/dist/build-factory.mjs +1 -1
  6. package/dist/change-CzEAoFVE.mjs +620 -0
  7. package/dist/change-CzEAoFVE.mjs.map +1 -0
  8. package/dist/connectors/slack.d.mts +4 -4
  9. package/dist/connectors/slack.mjs +81 -30
  10. package/dist/connectors/slack.mjs.map +1 -1
  11. package/dist/{approval-CDiBco2V.d.mts → dispatch-B77cuPxh.d.mts} +293 -99
  12. package/dist/{driver-CrZsy7sm.d.mts → driver-BJutB0PE.d.mts} +2 -2
  13. package/dist/driver-Bbd25rNM.mjs.map +1 -1
  14. package/dist/eve/index.d.mts +546 -120
  15. package/dist/eve/index.mjs +358 -82
  16. package/dist/eve/index.mjs.map +1 -1
  17. package/dist/{factory-config-ifd3JxiU.mjs → factory-config-BJcjsPKl.mjs} +42 -5
  18. package/dist/factory-config-BJcjsPKl.mjs.map +1 -0
  19. package/dist/{factory-config-Bu4mklm9.d.mts → factory-config-D2yU3G0c.d.mts} +21 -2
  20. package/dist/{github-wThBfbm-.d.mts → github-DXsxboUL.d.mts} +25 -2
  21. package/dist/github.d.mts +2 -2
  22. package/dist/github.mjs +47 -1
  23. package/dist/github.mjs.map +1 -1
  24. package/dist/index.d.mts +129 -59
  25. package/dist/index.mjs +336 -333
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/postgres/index.d.mts +1 -1
  28. package/package.json +1 -1
  29. package/dist/approval-DdvEKLiE.mjs +0 -132
  30. package/dist/approval-DdvEKLiE.mjs.map +0 -1
  31. package/dist/change-BPYhWKuL.mjs +0 -230
  32. package/dist/change-BPYhWKuL.mjs.map +0 -1
  33. package/dist/factory-config-ifd3JxiU.mjs.map +0 -1
@@ -1,8 +1,9 @@
1
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-BPYhWKuL.mjs";
2
+ import { b as taskQuestionSchema, c as findingSchema, i as requiresApproval, o as findingFingerprint, r as defaultApprovalPolicy } from "../approval-CwhTP1XB.mjs";
3
+ import { b as createCoordination, c as dispatchTask, i as levelSchema, t as changeEvidenceSchema } from "../change-CzEAoFVE.mjs";
4
4
  import { answerReviewComment, readPullRequestHead, readReviewComment, submitPullRequestReview } from "../github.mjs";
5
5
  import { z } from "zod";
6
+ import { createHash } from "node:crypto";
6
7
  import { defineHook } from "eve/hooks";
7
8
  import { defineTool } from "eve/tools";
8
9
  //#region src/eve/conflict.ts
@@ -369,7 +370,7 @@ function fetchPullRequestTool(stores, options) {
369
370
  const repoPath = options.repoPath ?? "repo";
370
371
  const depth = options.depth ?? 50;
371
372
  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
+ 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.",
373
374
  inputSchema: z.object({ changeId: changeIdSchema.describe("The change under review, from your starting message.") }),
374
375
  async execute({ changeId }, ctx) {
375
376
  const change = await stores.changes.get(changeId);
@@ -388,19 +389,31 @@ function fetchPullRequestTool(stores, options) {
388
389
  };
389
390
  const sandbox = await ctx.getSandbox();
390
391
  const tracking = `refs/remotes/origin/pr-${change.prNumber}`;
392
+ const baseTracking = `refs/remotes/origin/${repository.defaultBranch}`;
391
393
  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}`);
394
+ 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
395
  if (typeof result.exitCode === "number" && result.exitCode !== 0) return {
394
396
  fetched: false,
395
397
  reason: `git fetch of pull request ${change.prNumber} failed: ${(result.stderr ?? "").trim()}`
396
398
  };
397
399
  const head = await sandbox.run({ command: `${git} rev-parse HEAD` });
400
+ const base = await sandbox.run({ command: `${git} rev-parse ${baseTracking}` });
401
+ const mergeBase = await sandbox.run({ command: `${git} merge-base ${baseTracking} pr` });
402
+ if (typeof mergeBase.exitCode === "number" && mergeBase.exitCode !== 0) return {
403
+ fetched: false,
404
+ reason: `could not determine the pull request merge base: ${(mergeBase.stderr ?? "").trim()}`
405
+ };
406
+ const mergeBaseSha = mergeBase.stdout.trim();
398
407
  return {
399
408
  fetched: true,
400
409
  prNumber: change.prNumber,
401
410
  prUrl: change.prUrl,
402
411
  branch: "pr",
403
- headSha: head.stdout.trim()
412
+ headSha: head.stdout.trim(),
413
+ baseBranch: repository.defaultBranch,
414
+ baseSha: base.stdout.trim(),
415
+ mergeBaseSha,
416
+ diffCommand: `${git} diff ${mergeBaseSha}..pr`
404
417
  };
405
418
  }
406
419
  });
@@ -439,6 +452,7 @@ function invokeAgentTool(stores, options) {
439
452
  });
440
453
  const running = await dispatchTask(stores, task.id, {
441
454
  maxLaunchAttempts: 1,
455
+ budget: options.budget?.(),
442
456
  launch: async (dispatched) => {
443
457
  const headers = {
444
458
  "content-type": "application/json",
@@ -474,20 +488,147 @@ function invokeAgentTool(stores, options) {
474
488
  }
475
489
  });
476
490
  }
491
+ /**
492
+ * Give a conversational agent one bounded delegation surface backed by the
493
+ * application's route registry. The caller selects a declared route; trusted
494
+ * code owns task creation, budget enforcement, and the fresh target session.
495
+ */
496
+ function delegateAgentTool(stores, options) {
497
+ const doFetch = options.fetch ?? fetch;
498
+ const routeSummary = options.routes.routes.map((route) => `${route.id}: ${route.description}`).join("\n");
499
+ return defineTool({
500
+ 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,
501
+ inputSchema: z.object({
502
+ routeId: z.string().min(1).describe("One route id listed in this tool's description."),
503
+ repositoryIds: z.array(repositoryIdSchema).min(1),
504
+ signalId: signalIdSchema.optional().describe("Pending external signal this delegation accepts."),
505
+ parentTaskId: taskIdSchema.optional().describe("Existing task this work continues."),
506
+ changeId: changeIdSchema.optional().describe("Existing change the specialist should inspect."),
507
+ channel: z.string().min(1).describe("Originating conversation channel."),
508
+ address: z.string().min(1).describe("Originating conversation reply address."),
509
+ brief: z.string().min(1).max(4e3).describe("Facts and desired outcome, without hidden reasoning.")
510
+ }),
511
+ async execute(input, ctx) {
512
+ const route = options.routes.get(input.routeId);
513
+ if (route === void 0) throw new Error(`Unknown agent route ${input.routeId}.`);
514
+ const replyTo = {
515
+ channel: input.channel,
516
+ address: input.address
517
+ };
518
+ const attributes = ctx.session.auth.initiator?.attributes;
519
+ const repositoryAttribute = attributes?.factoryRepositoryIds;
520
+ const boundRepositoryIds = typeof repositoryAttribute === "string" ? repositoryAttribute.split(",").filter(Boolean) : repositoryAttribute;
521
+ 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.");
522
+ 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.");
523
+ for (const repositoryId of input.repositoryIds) {
524
+ const repository = await stores.repositories.get(repositoryId);
525
+ if (repository === null || !repository.enabled) throw new Error(`Repository ${repositoryId} is not enabled in this factory.`);
526
+ }
527
+ if (input.signalId !== void 0) {
528
+ const signal = await stores.signals.get(input.signalId);
529
+ if (signal === null) throw new Error(`Signal ${input.signalId} does not exist.`);
530
+ 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.");
531
+ if (signal.outcome === "pending") await stores.signals.decide(signal.id, "accepted", { reason: `Foreman selected ${route.id}.` });
532
+ else if (signal.outcome !== "accepted") throw new Error(`Signal ${signal.id} is already ${signal.outcome}.`);
533
+ }
534
+ const operator = ctx.session.auth.initiator?.principalId ?? "foreman";
535
+ if (route.taskKind === "code_change" && input.repositoryIds.length > 1) {
536
+ const [first, second, ...rest] = input.repositoryIds;
537
+ if (first === void 0 || second === void 0) throw new Error("Coordination requires two repositories.");
538
+ const coordination = await createCoordination(stores, {
539
+ repositoryIds: [
540
+ first,
541
+ second,
542
+ ...rest
543
+ ],
544
+ replyTo,
545
+ ...input.signalId === void 0 ? {} : { dedupeKey: `route:${input.signalId}:${route.id}` },
546
+ ...input.signalId === void 0 ? { operator } : { signalId: input.signalId }
547
+ });
548
+ await Promise.all(coordination.children.filter((child) => child.state === "queued").map((child) => dispatchRouteTask(stores, options, doFetch, route, child, input.changeId, input.brief)));
549
+ return {
550
+ taskId: coordination.parent.id,
551
+ childTaskIds: coordination.children.map(({ id }) => id),
552
+ routeId: route.id,
553
+ agent: route.agent,
554
+ state: coordination.parent.state
555
+ };
556
+ }
557
+ const task = await stores.tasks.create({
558
+ repositoryIds: input.repositoryIds,
559
+ kind: route.taskKind,
560
+ origin: input.signalId !== void 0 ? { signalId: input.signalId } : input.parentTaskId !== void 0 ? { parentTaskId: input.parentTaskId } : { operator },
561
+ replyTo,
562
+ changeId: input.changeId,
563
+ ...input.signalId === void 0 ? {} : { dedupeKey: `route:${input.signalId}:${route.id}` }
564
+ });
565
+ const running = task.state === "queued" ? await dispatchRouteTask(stores, options, doFetch, route, task, input.changeId, input.brief) : task;
566
+ return {
567
+ taskId: running.id,
568
+ routeId: route.id,
569
+ agent: route.agent,
570
+ state: running.state
571
+ };
572
+ }
573
+ });
574
+ }
575
+ async function dispatchRouteTask(stores, options, doFetch, route, task, changeId, brief) {
576
+ return dispatchTask(stores, task.id, {
577
+ maxLaunchAttempts: 1,
578
+ budget: options.budget?.(route),
579
+ launch: async (dispatched) => {
580
+ const headers = {
581
+ "content-type": "application/json",
582
+ ...await options.headers?.(dispatched, route)
583
+ };
584
+ const token = await options.token?.(route);
585
+ if (token !== void 0) headers.authorization = `Bearer ${token}`;
586
+ const message = [
587
+ `You are assigned factory task ${dispatched.id} through route ${route.id}.`,
588
+ changeId === void 0 ? null : `Change under review: ${changeId}.`,
589
+ brief,
590
+ "Read the task first and return exactly one bounded Factory outcome."
591
+ ].filter((line) => line !== null).join("\n\n");
592
+ const response = await doFetch(`${options.targetUrl(route)}/eve/v1/session`, {
593
+ method: "POST",
594
+ headers,
595
+ body: JSON.stringify({
596
+ message,
597
+ operationId: `factory-task:${dispatched.id}`
598
+ })
599
+ });
600
+ if (!response.ok) throw new Error(`${route.agent} refused the session: ${response.status}`);
601
+ }
602
+ });
603
+ }
477
604
  //#endregion
478
605
  //#region src/eve/post-review.ts
479
606
  const reviewCommentSchema = z.strictObject({
607
+ 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."),
608
+ title: z.string().min(1).max(200).optional().describe("Short finding title."),
480
609
  path: z.string().min(1).max(500).describe("Repository-relative file path."),
481
610
  line: z.number().int().min(1).describe("Line in the pull request's new version."),
482
611
  body: z.string().min(1).max(5e3).describe("Markdown explaining the finding and a concrete fix.")
483
612
  });
613
+ const reviewCommentsSchema = z.array(reviewCommentSchema).max(50).default([]).superRefine((comments, context) => {
614
+ const seen = /* @__PURE__ */ new Set();
615
+ comments.forEach((comment, index) => {
616
+ if (comment.key === void 0) return;
617
+ if (seen.has(comment.key)) context.addIssue({
618
+ code: "custom",
619
+ message: `duplicate finding key: ${comment.key}`,
620
+ path: [index, "key"]
621
+ });
622
+ seen.add(comment.key);
623
+ });
624
+ });
484
625
  /**
485
626
  * Publish an independent review of an existing pull request and finish the
486
627
  * review task without changing the pull request's factory lifecycle.
487
628
  */
488
629
  function postReviewTool(stores, options) {
489
630
  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.",
631
+ 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
632
  inputSchema: z.object({
492
633
  taskId: taskIdSchema.describe("Your code_review task."),
493
634
  changeId: changeIdSchema.describe("The pull request change from your starting message."),
@@ -498,7 +639,7 @@ function postReviewTool(stores, options) {
498
639
  ]),
499
640
  headSha: z.string().regex(/^[0-9a-f]{40}$/).describe("Commit returned by fetch_pull_request."),
500
641
  summary: z.string().min(1).max(5e3).describe("Markdown summary of what you reviewed and found."),
501
- comments: z.array(reviewCommentSchema).max(50).default([])
642
+ comments: reviewCommentsSchema
502
643
  }),
503
644
  async execute({ taskId, changeId, verdict, headSha, summary, comments }) {
504
645
  const task = await stores.tasks.get(taskId);
@@ -524,36 +665,68 @@ function postReviewTool(stores, options) {
524
665
  recorded: false,
525
666
  reason: `repository ${change.repositoryId} does not exist`
526
667
  };
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
668
+ const token = await options.tokenForRepository(repository.slug);
669
+ const completedComments = comments.map((comment) => {
670
+ const key = comment.key ?? legacyFindingKey(comment);
671
+ return {
672
+ ...comment,
673
+ key,
674
+ title: comment.title ?? "Review finding",
675
+ fingerprint: createHash("sha256").update(`${repository.slug.toLowerCase()}#${change.prNumber}:${key}`).digest("hex")
676
+ };
536
677
  });
537
- await options.onPublished?.({
678
+ const completed = {
538
679
  taskId,
539
680
  changeId,
540
681
  repository: repository.slug,
682
+ pullRequestNumber: change.prNumber,
541
683
  headSha,
542
684
  verdict,
543
685
  summary,
544
- comments,
545
- review
546
- });
686
+ comments: completedComments
687
+ };
688
+ const publish = options.publishReviews !== false;
689
+ let review;
690
+ if (publish) {
691
+ review = await submitPullRequestReview({
692
+ repository: repository.slug,
693
+ token,
694
+ fetch: options.fetch,
695
+ pullRequestNumber: change.prNumber,
696
+ commitId: headSha,
697
+ event: verdict === "approve" ? "APPROVE" : verdict === "request_changes" ? "REQUEST_CHANGES" : "COMMENT",
698
+ body: summary,
699
+ comments: completedComments
700
+ });
701
+ await options.onPublished?.({
702
+ ...completed,
703
+ review
704
+ });
705
+ } else {
706
+ const head = await readPullRequestHead({
707
+ repository: repository.slug,
708
+ token,
709
+ fetch: options.fetch,
710
+ pullRequestNumber: change.prNumber
711
+ });
712
+ if (head.sha !== headSha) throw new Error(`pull request changed after review: expected ${headSha}, found ${head.sha}`);
713
+ }
714
+ await options.onCompleted?.(completed);
547
715
  await stores.tasks.transition(taskId, "verifying", { reason: summary });
716
+ const finished = await stores.tasks.transition(taskId, "succeeded");
548
717
  return {
549
718
  recorded: true,
550
719
  verdict,
551
- review,
552
- taskState: (await stores.tasks.transition(taskId, "succeeded")).state
720
+ published: review !== void 0,
721
+ ...review === void 0 ? {} : { review },
722
+ taskState: finished.state
553
723
  };
554
724
  }
555
725
  });
556
726
  }
727
+ function legacyFindingKey(comment) {
728
+ return `legacy-${createHash("sha256").update(`${comment.path}:${comment.line}:${comment.body}`).digest("hex").slice(0, 24)}`;
729
+ }
557
730
  //#endregion
558
731
  //#region src/eve/review.ts
559
732
  function reviewCommentTools(stores, options) {
@@ -686,7 +859,7 @@ function reviewCommentTools(stores, options) {
686
859
  * as still in progress. Fail closed: the agent cannot mark a change ready
687
860
  * without supplying every evidence field.
688
861
  */
689
- function recordVerificationTool(stores) {
862
+ function recordVerificationTool(stores, options = {}) {
690
863
  return defineTool({
691
864
  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
865
  inputSchema: z.object({
@@ -728,6 +901,12 @@ function recordVerificationTool(stores) {
728
901
  if (change.taskId !== void 0) {
729
902
  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);
730
903
  }
904
+ await options.presentResult?.({
905
+ taskId,
906
+ changeId,
907
+ verdict,
908
+ summary
909
+ });
731
910
  return {
732
911
  recorded: true,
733
912
  verdict,
@@ -755,6 +934,25 @@ const recordChangeInputSchema = z.object({
755
934
  confidence: levelSchema.describe("How certain you are the change is correct."),
756
935
  summary: z.string().min(1).describe("One-sentence description of the change.")
757
936
  });
937
+ async function createAndPresentQuestion(stores, parent, question, presentQuestion) {
938
+ const task = await stores.tasks.create({
939
+ repositoryIds: parent.repositoryIds,
940
+ kind: "question",
941
+ origin: { parentTaskId: parent.id },
942
+ replyTo: parent.replyTo,
943
+ question
944
+ });
945
+ const waiting = await stores.tasks.transition(task.id, "needs_human");
946
+ try {
947
+ if (parent.state === "running") await stores.tasks.transition(parent.id, "needs_human", { reason: `waiting for answer to question ${waiting.id}` });
948
+ await presentQuestion(waiting);
949
+ } catch (error) {
950
+ if ((await stores.tasks.get(parent.id))?.state === "needs_human") await stores.tasks.transition(parent.id, "queued", { reason: `delivery of question ${waiting.id} failed` });
951
+ if ((await stores.tasks.get(waiting.id))?.state === "needs_human") await stores.tasks.transition(waiting.id, "cancelled", { reason: "question delivery failed" });
952
+ throw error;
953
+ }
954
+ return waiting;
955
+ }
758
956
  /**
759
957
  * The factory's store operations wrapped as eve tools. Expose a tool to an
760
958
  * agent by re-exporting it from a file under agent/tools/, e.g.
@@ -763,6 +961,7 @@ const recordChangeInputSchema = z.object({
763
961
  */
764
962
  function factoryTools(stores, options = {}) {
765
963
  const recordChangeGate = options.recordChangeGate;
964
+ const verification = options.verification ?? "required";
766
965
  return {
767
966
  get_task: defineTool({
768
967
  description: "Read one factory task by id: its kind, state, repositories, origin, and results.",
@@ -790,7 +989,7 @@ function factoryTools(stores, options = {}) {
790
989
  }
791
990
  }),
792
991
  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.",
992
+ 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
993
  inputSchema: recordChangeInputSchema,
795
994
  ...recordChangeGate === void 0 ? {} : { approval: async ({ toolInput }) => {
796
995
  const parsed = recordChangeInputSchema.safeParse(toolInput);
@@ -805,28 +1004,51 @@ function factoryTools(stores, options = {}) {
805
1004
  };
806
1005
  } },
807
1006
  async execute({ taskId, repositoryId, prNumber, prUrl, risk, confidence, summary }) {
808
- const change = await stores.changes.create({
809
- repositoryId,
1007
+ const currentTask = await stores.tasks.get(taskId);
1008
+ if (currentTask === null) throw new Error(`task ${taskId} does not exist`);
1009
+ if (currentTask.state !== "running" && currentTask.state !== "verifying" && currentTask.state !== "succeeded") throw new Error(`task ${taskId} is ${currentTask.state}, not running`);
1010
+ const linkedChange = currentTask.changeId === void 0 ? void 0 : await stores.changes.get(currentTask.changeId);
1011
+ if (currentTask.changeId !== void 0 && linkedChange === null) throw new Error(`change ${currentTask.changeId} does not exist`);
1012
+ let change = linkedChange ?? (await stores.changes.list()).find((candidate) => candidate.taskId === taskId);
1013
+ if (change === void 0) {
1014
+ if (currentTask.state !== "running") throw new Error(`task ${taskId} has no recorded change`);
1015
+ change = await stores.changes.create({
1016
+ repositoryId,
1017
+ taskId,
1018
+ prNumber,
1019
+ prUrl,
1020
+ author: "factory",
1021
+ risk,
1022
+ confidence,
1023
+ evidence: {
1024
+ reproVerified: false,
1025
+ testsAdded: 0,
1026
+ checksGreen: false,
1027
+ attackPassed: false,
1028
+ scopeMatches: false
1029
+ }
1030
+ });
1031
+ }
1032
+ if (currentTask.state === "running") await options.presentChange?.({
810
1033
  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
- }
1034
+ changeId: change.id,
1035
+ verification,
1036
+ summary
1037
+ });
1038
+ let task = currentTask;
1039
+ if (task.state === "running") task = await stores.tasks.transition(taskId, "verifying", {
1040
+ patch: { changeId: change.id },
1041
+ reason: summary
823
1042
  });
1043
+ if (verification === "required") return {
1044
+ change,
1045
+ task
1046
+ };
1047
+ if (change.state === "verifying") change = await stores.changes.transition(change.id, "ready", { reason: "independent verification is not configured" });
1048
+ if (task.state === "verifying") task = await stores.tasks.transition(taskId, "succeeded", { reason: "change recorded for human review without independent verification" });
824
1049
  return {
825
1050
  change,
826
- task: await stores.tasks.transition(taskId, "verifying", {
827
- patch: { changeId: change.id },
828
- reason: summary
829
- })
1051
+ task
830
1052
  };
831
1053
  }
832
1054
  }),
@@ -857,40 +1079,57 @@ function factoryTools(stores, options = {}) {
857
1079
  };
858
1080
  }
859
1081
  }),
860
- report_blocked: defineTool({
861
- description: "Mark a running task as needing a human because the work cannot continue without help.",
862
- 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.",
1082
+ request_human_input: defineTool({
1083
+ description: "Request human input or action and pause your task until the human replies. The parent task determines the repositories and reply destination.",
872
1084
  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."),
1085
+ parentTaskId: taskIdSchema.describe("Your running task, which the answer will unblock."),
877
1086
  question: taskQuestionSchema.omit({
878
1087
  answer: true,
879
1088
  answeredBy: true
880
1089
  })
881
1090
  }),
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
1091
+ async execute({ parentTaskId, question }) {
1092
+ if (options.presentQuestion === void 0) throw new Error("request_human_input requires a configured question presenter.");
1093
+ const parent = await stores.tasks.get(parentTaskId);
1094
+ if (parent === null) throw new Error(`Parent task ${parentTaskId} does not exist.`);
1095
+ if (parent.state !== "running" && parent.state !== "needs_human") throw new Error(`Parent task ${parentTaskId} is ${parent.state}, not running.`);
1096
+ return createAndPresentQuestion(stores, parent, question, options.presentQuestion);
1097
+ }
1098
+ }),
1099
+ answer_question: defineTool({
1100
+ description: "Record the authenticated user's answer to a waiting question and requeue the parent specialist task.",
1101
+ inputSchema: z.object({
1102
+ questionTaskId: taskIdSchema,
1103
+ answer: z.string().min(1).max(4e3)
1104
+ }),
1105
+ async execute({ questionTaskId, answer }, ctx) {
1106
+ const questionTask = await stores.tasks.get(questionTaskId);
1107
+ if (questionTask === null || questionTask.kind !== "question" || questionTask.state !== "needs_human" || questionTask.question === void 0) throw new Error(`Question task ${questionTaskId} is not waiting for an answer.`);
1108
+ const attributes = ctx.session.auth.initiator?.attributes;
1109
+ if (attributes?.factoryReplyChannel !== questionTask.replyTo.channel || attributes.factoryReplyAddress !== questionTask.replyTo.address) throw new Error("Question reply does not match the authenticated conversation.");
1110
+ const repositoryAttribute = attributes.factoryRepositoryIds;
1111
+ const boundRepositoryIds = typeof repositoryAttribute === "string" ? repositoryAttribute.split(",").filter(Boolean) : repositoryAttribute;
1112
+ if (boundRepositoryIds === void 0 || boundRepositoryIds.length !== questionTask.repositoryIds.length || boundRepositoryIds.some((id) => !questionTask.repositoryIds.includes(id))) throw new Error("Question repositories do not match the authenticated conversation.");
1113
+ const answeredBy = ctx.session.auth.initiator?.principalId ?? "operator";
1114
+ const answered = await stores.tasks.transition(questionTask.id, "succeeded", {
1115
+ patch: { question: {
1116
+ ...questionTask.question,
1117
+ answer,
1118
+ answeredBy
1119
+ } },
1120
+ reason: "question answered through Foreman"
892
1121
  });
893
- return stores.tasks.transition(task.id, "needs_human");
1122
+ const parentTaskId = questionTask.origin.parentTaskId;
1123
+ if (parentTaskId === void 0) return {
1124
+ questionTask: answered,
1125
+ parentTask: null
1126
+ };
1127
+ const parent = await stores.tasks.get(parentTaskId);
1128
+ if (parent === null) throw new Error(`Parent task ${parentTaskId} does not exist.`);
1129
+ return {
1130
+ questionTask: answered,
1131
+ parentTask: parent.state === "needs_human" ? await stores.tasks.transition(parent.id, "queued", { reason: `question ${questionTask.id} answered by ${answeredBy}` }) : parent
1132
+ };
894
1133
  }
895
1134
  }),
896
1135
  append_receipt: defineTool({
@@ -910,25 +1149,62 @@ function factoryTools(stores, options = {}) {
910
1149
  })
911
1150
  };
912
1151
  }
1152
+ const terminalTaskStates = /* @__PURE__ */ new Set([
1153
+ "succeeded",
1154
+ "failed",
1155
+ "cancelled"
1156
+ ]);
913
1157
  /**
914
1158
  * Observe-only eve hooks that keep the ledger honest about session outcomes.
915
1159
  * Mount by re-exporting from agent/hooks/, e.g.
916
- * `export default factoryHooks(stores).factory;`.
1160
+ * `export default factoryHooks(stores, { presentQuestion }).factory;`.
1161
+ *
1162
+ * step.completed: records the step's tokens and AI Gateway cost on the bound
1163
+ * task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
1164
+ *
1165
+ * turn.completed: settles the bound task's recorded usage if the task is
1166
+ * already terminal (the sweep handles tasks that end later), then moves any
1167
+ * still-running task at this session's reply address behind a surfaced human
1168
+ * question, because the agent finished talking without recording an outcome.
917
1169
  *
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.
1170
+ * Every store call is swallowed: a hook throw fails the turn.
922
1171
  */
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
- } } }) };
1172
+ function factoryHooks(stores, options) {
1173
+ const taskIdAttribute = options.taskIdAttribute ?? "factoryTaskId";
1174
+ function boundTaskId(ctx) {
1175
+ const parsed = taskIdSchema.safeParse(ctx.session.auth.initiator?.attributes[taskIdAttribute]);
1176
+ return parsed.success ? parsed.data : void 0;
1177
+ }
1178
+ async function settleIfTerminal(taskId) {
1179
+ const task = await stores.tasks.get(taskId);
1180
+ if (task?.usage === void 0 || task.settledUsd !== void 0 || !terminalTaskStates.has(task.state)) return;
1181
+ await stores.tasks.settle(task.id, { totalUsd: task.usage.costUsd }, { reason: "measured usage settled at turn end" });
1182
+ }
1183
+ return { factory: defineHook({ events: {
1184
+ async "step.completed"(event, ctx) {
1185
+ const taskId = boundTaskId(ctx);
1186
+ const usage = event.data.usage;
1187
+ if (taskId === void 0 || usage === void 0) return;
1188
+ await stores.tasks.recordUsage(taskId, {
1189
+ turnId: event.data.turnId,
1190
+ stepIndex: event.data.stepIndex,
1191
+ ...usage
1192
+ }).catch(() => void 0);
1193
+ },
1194
+ async "turn.completed"(_event, ctx) {
1195
+ const taskId = boundTaskId(ctx);
1196
+ if (taskId !== void 0) await settleIfTerminal(taskId).catch(() => void 0);
1197
+ const address = ctx.channel.continuationToken;
1198
+ if (address === void 0) return;
1199
+ const abandoned = (await stores.tasks.list()).filter((task) => task.state === "running" && task.replyTo.address === address);
1200
+ for (const task of abandoned) await createAndPresentQuestion(stores, task, {
1201
+ text: "The worker stopped without recording an outcome. Please review the task and reply when Factory should retry it.",
1202
+ options: ["Retry the task"]
1203
+ }, options.presentQuestion).catch(() => void 0);
1204
+ }
1205
+ } }) };
930
1206
  }
931
1207
  //#endregion
932
- export { deepsecScanTool, factoryHooks, factoryTools, fetchPullRequestTool, findingFromDeepsec, invokeAgentTool, mergeConflictPayloadSchema, mergeConflictTools, postReviewTool, recordFinding, recordFindingTool, recordVerificationTool, reviewCommentTools };
1208
+ export { deepsecScanTool, delegateAgentTool, factoryHooks, factoryTools, fetchPullRequestTool, findingFromDeepsec, invokeAgentTool, mergeConflictPayloadSchema, mergeConflictTools, postReviewTool, recordFinding, recordFindingTool, recordVerificationTool, reviewCommentTools };
933
1209
 
934
1210
  //# sourceMappingURL=index.mjs.map