beflow 0.1.0 → 0.2.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/src/core/run.ts CHANGED
@@ -8,7 +8,7 @@ import type { AgentDriver, AgentRunResult, RunOptions } from "../agent/driver.ts
8
8
  import type { Report, ReportStatus } from "../agent/report.ts";
9
9
  import type { Config, Project, Registry } from "../config/schema.ts";
10
10
  import type { Issue, JobKind, Resolved } from "../model/types.ts";
11
- import { resolve } from "../resolve/precedence.ts";
11
+ import { resolve, resolvePolicy, resolvePr } from "../resolve/precedence.ts";
12
12
  import type { Comment, Tracker } from "../trackers/tracker.ts";
13
13
  import { renderContinuation } from "./continuation.ts";
14
14
  import { DECISION_HOLD_MESSAGE, isDecisionHeld } from "./decision.ts";
@@ -17,6 +17,10 @@ import { injectAcpxMcp, nodeMcpFs } from "./mcp.ts";
17
17
  import type { McpFs, McpServer } from "./mcp.ts";
18
18
  import { escalationDetail, notifyEscalation } from "./notify.ts";
19
19
  import type { Notifier } from "./notify.ts";
20
+ import { computeChangedFiles, defaultPolicyExec, evaluatePolicy } from "./policy.ts";
21
+ import type { PolicyExec, PolicyResult } from "./policy.ts";
22
+ import { closePrAndDeleteBranch, detectBaseBranch, editPr, hasCommits, markReady, openDraftPr } from "./pr.ts";
23
+ import type { PrRef } from "./pr.ts";
20
24
  import type { PromptSet } from "./prompts.ts";
21
25
  import { renderContract, renderLinkedContext, renderTask } from "./prompts.ts";
22
26
  import { defaultGateExec, resolveQualityGate, runQualityGate } from "./qualitygate.ts";
@@ -24,7 +28,7 @@ import type { GateExec } from "./qualitygate.ts";
24
28
  import { deleteRecord, loadRecord, resolveRunsDir, saveRecord, systemClock } from "./runstore.ts";
25
29
  import type { Clock, RunRecord, RunStoreFs } from "./runstore.ts";
26
30
  import { formatTelemetryLine, resolveTelemetryInComment } from "./runsview.ts";
27
- import { createWorktree, removeWorktree, resolveWorktreeDir, sanitizeKey } from "./worktree.ts";
31
+ import { bunExec, createWorktree, removeWorktree, resolveWorktreeDir, sanitizeKey } from "./worktree.ts";
28
32
  import type { Exec } from "./worktree.ts";
29
33
  import { applyReport, buildCommentBody, defaultDoneState } from "./writeback.ts";
30
34
  import type { WritebackResult } from "./writeback.ts";
@@ -82,7 +86,7 @@ export async function resolveRun(
82
86
 
83
87
  const resolved = resolve({
84
88
  cli,
85
- global: config.defaults,
89
+ global: config,
86
90
  issue: {
87
91
  areas: issue.areas,
88
92
  state: { group: issue.state.group },
@@ -225,6 +229,8 @@ export interface RunIssueDeps {
225
229
  mcpServers?: McpServer[];
226
230
  mcpFs?: McpFs;
227
231
  gateExec?: GateExec;
232
+ prExec?: Exec;
233
+ policyExec?: PolicyExec;
228
234
  }
229
235
 
230
236
  const RESUME_STATUSES: ReadonlySet<RunRecord["status"]> = new Set([
@@ -252,6 +258,18 @@ async function postInReviewInstructionOnce(tracker: Tracker, issue: Issue, log:
252
258
  log(`beflow: ${issue.key} → In Review; posted change-request instructions`);
253
259
  }
254
260
 
261
+ function beflowPrTitle(issue: Issue): string {
262
+ return `[beflow] ${issue.key}: ${issue.title}`;
263
+ }
264
+
265
+ function beflowPrBody(issue: Issue, summary?: string): string {
266
+ const base = `Automated implementation of ${issue.key} by beflow.`;
267
+ if (summary !== undefined && summary.trim() !== "") {
268
+ return `${base}\n\n${summary.trim()}`;
269
+ }
270
+ return base;
271
+ }
272
+
255
273
  export interface RunResult {
256
274
  issue: Issue;
257
275
  resolved: Resolved;
@@ -394,6 +412,15 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
394
412
  const effectiveRepoPath =
395
413
  isResume && prior !== null && prior.repoPath !== undefined ? prior.repoPath : resolved.repoPath;
396
414
 
415
+ // BEFLOW-OWNED PR (opt-in): when the project resolves PR ownership to `beflow`,
416
+ // The agent only pushes its branch — beflow opens/enriches/marks-ready the PR and
417
+ // Runs the post-run policy gate. This only engages for an autonomous implement run
418
+ // With a worktree branch; every other shape keeps the agent-owned behavior.
419
+ const resolvedPr = resolvePr(deps.config, deps.registry, projectKeyOf(key));
420
+ const resolvedPolicy = resolvePolicy(deps.config, deps.registry, projectKeyOf(key));
421
+ const beflowOwned =
422
+ resolvedPr.owner === "beflow" && useWorktree && effectiveJobKind === "implement" && branch !== undefined;
423
+
397
424
  // attempts counts CONSECUTIVE crash resumes only. A fresh dispatch resets to 0,
398
425
  // and a human-driven re-dispatch (rework/answered) passes a continuation, so it
399
426
  // also resets to 0 — only an unattended crash-resume increments the streak.
@@ -419,8 +446,8 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
419
446
  saveRecord(runsDir, record, deps.runsFs);
420
447
 
421
448
  await moveToInProgress(deps.tracker, issue);
422
- if (deps.config.defaults.assignee !== undefined) {
423
- await deps.tracker.assign(issue, deps.config.defaults.assignee);
449
+ if (deps.config.assignee !== undefined) {
450
+ await deps.tracker.assign(issue, deps.config.assignee);
424
451
  }
425
452
 
426
453
  const acpCommand = resolveAcpCommand(effectiveAgent, deps.config.agents[effectiveAgent]);
@@ -435,7 +462,7 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
435
462
 
436
463
  const baseTask =
437
464
  renderTask(deps.prompts, issue, resolved.repo) +
438
- (await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log));
465
+ (await gatherLinkedContext(deps.tracker, issue, deps.config.linkedContext !== false, log));
439
466
  const task =
440
467
  deps.continuation !== undefined
441
468
  ? `${deps.continuation}\n\n${baseTask}`
@@ -446,7 +473,7 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
446
473
  const sleep = deps.sleep ?? realSleep;
447
474
  const pollMs = deps.manualMovePollMs ?? DEFAULT_MANUAL_MOVE_POLL_MS;
448
475
  const poller =
449
- deps.config.defaults.onManualMove === "abort"
476
+ deps.config.onManualMove === "abort"
450
477
  ? startManualMovePoller({
451
478
  acpCommand,
452
479
  cwd,
@@ -466,7 +493,7 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
466
493
  function buildRunOptions(runTask: string): RunOptions {
467
494
  return {
468
495
  acpCommand,
469
- contract: renderContract(deps.prompts, effectiveJobKind, issue, resolved.repo),
496
+ contract: renderContract(deps.prompts, effectiveJobKind, issue, resolved.repo, beflowOwned),
470
497
  cwd,
471
498
  nonInteractive: "fail",
472
499
  runMode: "autonomous",
@@ -548,6 +575,68 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
548
575
  return formatTelemetryLine(result.stream.usage, telemetryModel, record.attempts);
549
576
  }
550
577
 
578
+ // Park the run as FAILED while KEEPING the worktree (and any draft PR): writes the
579
+ // Failed report back, persists a failed record, and escalates. Used by the
580
+ // Beflow-owned PR paths (no-op, gh/policy-layer failure) which are all retryable.
581
+ async function parkBeflowFailed(summary: string): Promise<RunResult> {
582
+ const failedReport: Report = { status: "failed", summary };
583
+ const failedApplied = await applyReport(deps.tracker, issue, failedReport, effectiveJobKind, telemetryLine());
584
+ saveRecord(
585
+ runsDir,
586
+ {
587
+ ...record,
588
+ attempts: 0,
589
+ report: failedReport,
590
+ status: "failed",
591
+ updatedAt: clock(),
592
+ ...(result.stream.usage !== undefined ? { usage: result.stream.usage } : {}),
593
+ },
594
+ deps.runsFs,
595
+ );
596
+ await notifyEscalation(deps.notify, issue, "failed", escalationDetail(failedReport));
597
+ return { applied: failedApplied, cwd, issue, resolved, result };
598
+ }
599
+
600
+ // BEFLOW-OWNED PR — STEP 1+2 (before the gate): the agent reported `done` and only
601
+ // Pushed its branch. First confirm it produced commits; if not, the run is empty —
602
+ // Park failed and keep the worktree (no PR). Otherwise open a DRAFT PR from the
603
+ // Pushed branch and stamp its URL onto the report so the writeback path links it.
604
+ // The draft is enriched + marked ready (or closed) by the post-gate policy step.
605
+ const prExec = deps.prExec ?? bunExec;
606
+ let openedPr: PrRef | undefined;
607
+ let beflowBase = "";
608
+ if (beflowOwned && branch !== undefined && result.report?.status === "done") {
609
+ try {
610
+ beflowBase = await detectBaseBranch(resolved.repo, resolvedPr.baseBranch, prExec);
611
+ if (!(await hasCommits(cwd, beflowBase, prExec))) {
612
+ log(`beflow: ${key} — agent produced no commits; parking as failed (worktree kept)`);
613
+ return await parkBeflowFailed(
614
+ "The agent reported done but produced no commits on its branch; nothing to open a PR from.",
615
+ );
616
+ }
617
+ openedPr = await openDraftPr(
618
+ {
619
+ base: beflowBase,
620
+ body: beflowPrBody(issue),
621
+ cwd,
622
+ head: branch,
623
+ repo: resolved.repo,
624
+ title: beflowPrTitle(issue),
625
+ },
626
+ prExec,
627
+ );
628
+ result = { ...result, report: { ...result.report, prUrl: openedPr.url } };
629
+ log(`beflow: ${key} — opened draft PR ${openedPr.url}`);
630
+ } catch (err) {
631
+ log(
632
+ `beflow: ${key} — PR layer failed (${err instanceof Error ? err.message : String(err)}); parking as failed (worktree kept)`,
633
+ );
634
+ return await parkBeflowFailed(
635
+ `beflow could not open the pull request: ${err instanceof Error ? err.message : String(err)}`,
636
+ );
637
+ }
638
+ }
639
+
551
640
  // QUALITY GATE (opt-in, autonomous-only): before an implement `done` report is
552
641
  // Allowed to open a PR / advance to In Review, run the project check command(s) in
553
642
  // The worktree. On RED, re-prompt the SAME live agent session once with the failing
@@ -575,11 +664,15 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
575
664
  id: "quality-gate",
576
665
  isBot: false,
577
666
  };
578
- const reworkTask = renderContinuation(deps.prompts, {
579
- newComments: [gateComment],
580
- ...(result.report.prUrl !== undefined ? { prUrl: result.report.prUrl } : {}),
581
- priorReport: result.report,
582
- });
667
+ const reworkTask = renderContinuation(
668
+ deps.prompts,
669
+ {
670
+ newComments: [gateComment],
671
+ ...(result.report.prUrl !== undefined ? { prUrl: result.report.prUrl } : {}),
672
+ priorReport: result.report,
673
+ },
674
+ beflowOwned,
675
+ );
583
676
  const reworked = await deps.driver.run(buildRunOptions(reworkTask), (evt) => {
584
677
  log(`acpx: ${JSON.stringify(evt)}`);
585
678
  });
@@ -616,8 +709,13 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
616
709
  }
617
710
  if (reworked.report?.status === "done" && (reworkGate === undefined || reworkGate.passed)) {
618
711
  // Rework produced a fresh `done` report AND the gate is green (or the
619
- // Re-run threw → fail open) — adopt the new report and fall through.
620
- result = reworked;
712
+ // Re-run threw → fail open) — adopt the new report and fall through. In
713
+ // Beflow-owned mode the agent never sets prUrl, so re-stamp the already
714
+ // Opened draft PR so the policy + writeback steps still link it.
715
+ result =
716
+ openedPr !== undefined
717
+ ? { ...reworked, report: { ...reworked.report, prUrl: openedPr.url } }
718
+ : reworked;
621
719
  } else {
622
720
  // Still red, or the rework didn't re-emit a `done` report → FAILED. Route
623
721
  // Through applyReport(failed) + escalation, and persist the run record with
@@ -659,6 +757,97 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
659
757
  }
660
758
  }
661
759
 
760
+ // BEFLOW-OWNED PR — STEP 4 (after a green gate): evaluate the post-run policy over
761
+ // The run's diff and decide the draft PR's fate. `block` closes the PR + branch and
762
+ // Routes the issue to the blocked/Needs-Input path (NOT In Review); `require_approval`
763
+ // Enriches the body but leaves the PR draft as the review artifact; `allow` enriches
764
+ // And marks it ready. Any thrown PR/policy-layer error parks the run as failed while
765
+ // Keeping the worktree + draft PR (retryable). Decided here so the shared writeback
766
+ // Below still moves an allowed/approval run to In Review with the PR linked.
767
+ let awaitsApproval = false;
768
+ if (beflowOwned && openedPr !== undefined && branch !== undefined && result.report?.status === "done") {
769
+ const policyExec = deps.policyExec ?? defaultPolicyExec;
770
+ let decision: PolicyResult;
771
+ try {
772
+ const changedFiles = await computeChangedFiles(cwd, beflowBase, prExec);
773
+ decision = await evaluatePolicy(
774
+ {
775
+ agent: effectiveAgent,
776
+ baseBranch: beflowBase,
777
+ changedFiles,
778
+ issueKey: key,
779
+ jobKind: effectiveJobKind,
780
+ repo: resolved.repo,
781
+ },
782
+ resolvedPolicy,
783
+ policyExec,
784
+ cwd,
785
+ );
786
+ } catch (err) {
787
+ log(
788
+ `beflow: ${key} — policy layer failed (${err instanceof Error ? err.message : String(err)}); parking as failed (worktree + draft PR kept)`,
789
+ );
790
+ return await parkBeflowFailed(
791
+ `beflow could not evaluate the post-run policy: ${err instanceof Error ? err.message : String(err)}`,
792
+ );
793
+ }
794
+
795
+ if (decision.decision === "block") {
796
+ try {
797
+ await closePrAndDeleteBranch(openedPr, resolved.repo, branch, cwd, prExec);
798
+ } catch (err) {
799
+ log(
800
+ `beflow: ${key} — closing the blocked PR failed (${err instanceof Error ? err.message : String(err)}); parking as failed`,
801
+ );
802
+ return await parkBeflowFailed(
803
+ `beflow could not close the policy-blocked pull request: ${err instanceof Error ? err.message : String(err)}`,
804
+ );
805
+ }
806
+ const blockedReport: Report = {
807
+ status: "blocked",
808
+ summary: `Policy blocked this change: ${decision.reason}`,
809
+ };
810
+ const blockedApplied = await applyReport(
811
+ deps.tracker,
812
+ issue,
813
+ blockedReport,
814
+ effectiveJobKind,
815
+ telemetryLine(),
816
+ );
817
+ saveRecord(
818
+ runsDir,
819
+ {
820
+ ...record,
821
+ attempts: 0,
822
+ report: blockedReport,
823
+ status: "blocked",
824
+ updatedAt: clock(),
825
+ ...(result.stream.usage !== undefined ? { usage: result.stream.usage } : {}),
826
+ },
827
+ deps.runsFs,
828
+ );
829
+ await notifyEscalation(deps.notify, issue, "blocked", escalationDetail(blockedReport));
830
+ log(`beflow: ${key} — policy blocked; closed PR + branch and routed to Needs Input`);
831
+ return { applied: blockedApplied, cwd, issue, resolved, result };
832
+ }
833
+
834
+ try {
835
+ await editPr(openedPr, resolved.repo, { body: beflowPrBody(issue, result.report.summary) }, prExec);
836
+ if (decision.decision === "allow") {
837
+ await markReady(openedPr, resolved.repo, prExec);
838
+ } else {
839
+ awaitsApproval = true;
840
+ }
841
+ } catch (err) {
842
+ log(
843
+ `beflow: ${key} — finalizing the PR failed (${err instanceof Error ? err.message : String(err)}); parking as failed (worktree + draft PR kept)`,
844
+ );
845
+ return await parkBeflowFailed(
846
+ `beflow could not finalize the pull request: ${err instanceof Error ? err.message : String(err)}`,
847
+ );
848
+ }
849
+ }
850
+
662
851
  let applied: WritebackResult | undefined;
663
852
  if (result.report !== null) {
664
853
  applied = await applyReport(deps.tracker, issue, result.report, effectiveJobKind, telemetryLine());
@@ -686,6 +875,12 @@ export async function runIssue(key: string, cli: Partial<Resolved>, deps: RunIss
686
875
  deps.runsFs,
687
876
  );
688
877
  await postInReviewInstructionOnce(deps.tracker, issue, log);
878
+ if (awaitsApproval) {
879
+ const approvalNote = `This change requires human approval before merge: the draft pull request is the review artifact. Approve it to mark it ready and merge.`;
880
+ await deps.tracker.comment(issue, approvalNote);
881
+ await notifyEscalation(deps.notify, issue, "needs_input", approvalNote);
882
+ log(`beflow: ${key} — policy requires approval; draft PR left for human review`);
883
+ }
689
884
  } else {
690
885
  if (worktreeCreated || isResume) {
691
886
  if (git !== undefined) {
@@ -791,8 +986,8 @@ export async function runSupervised(
791
986
  const clock = deps.clock ?? systemClock;
792
987
 
793
988
  await moveToInProgress(deps.tracker, issue);
794
- if (deps.config.defaults.assignee !== undefined) {
795
- await deps.tracker.assign(issue, deps.config.defaults.assignee);
989
+ if (deps.config.assignee !== undefined) {
990
+ await deps.tracker.assign(issue, deps.config.assignee);
796
991
  }
797
992
 
798
993
  const record: RunRecord = {
@@ -818,7 +1013,7 @@ export async function runSupervised(
818
1013
  const contract = renderContract(deps.prompts, resolved.jobKind, issue, resolved.repo);
819
1014
  const task =
820
1015
  renderTask(deps.prompts, issue, resolved.repo) +
821
- (await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log));
1016
+ (await gatherLinkedContext(deps.tracker, issue, deps.config.linkedContext !== false, log));
822
1017
 
823
1018
  // Inject the managed `.acpxrc.json` into the repo checkout for the interactive
824
1019
  // Acpx launch, then restore it in a finally so the user's repo is left exactly
@@ -946,9 +1141,9 @@ export async function runOpen(key: string, cli: Partial<Resolved>, deps: RunOpen
946
1141
 
947
1142
  log(`beflow: moving ${key} to In Progress`);
948
1143
  await moveToInProgress(deps.tracker, issue);
949
- if (deps.config.defaults.assignee !== undefined) {
950
- log(`beflow: assigning ${key} to ${deps.config.defaults.assignee}`);
951
- await deps.tracker.assign(issue, deps.config.defaults.assignee);
1144
+ if (deps.config.assignee !== undefined) {
1145
+ log(`beflow: assigning ${key} to ${deps.config.assignee}`);
1146
+ await deps.tracker.assign(issue, deps.config.assignee);
952
1147
  }
953
1148
 
954
1149
  const record: RunRecord = {
@@ -968,7 +1163,7 @@ export async function runOpen(key: string, cli: Partial<Resolved>, deps: RunOpen
968
1163
  log(`beflow: launching ${agentCfg.command} in ${cwd}`);
969
1164
  const task =
970
1165
  renderTask(deps.prompts, issue, resolved.repo) +
971
- (await gatherLinkedContext(deps.tracker, issue, deps.config.defaults.linkedContext !== false, log)) +
1166
+ (await gatherLinkedContext(deps.tracker, issue, deps.config.linkedContext !== false, log)) +
972
1167
  OPEN_SESSION_TRAILER;
973
1168
  await openIssue({
974
1169
  args: agentCfg.args ?? [],
@@ -5,7 +5,7 @@ import type { RunRecord } from "./runstore.ts";
5
5
  // Project-over-default-over-false resolution of the per-project telemetry-in-comment
6
6
  // Toggle. Returns false when neither layer opts in.
7
7
  export function resolveTelemetryInComment(config: Config, registry: Registry, projectKey: string): boolean {
8
- return registry.projects[projectKey]?.telemetry?.inComment ?? config.defaults.telemetry?.inComment ?? false;
8
+ return registry.projects[projectKey]?.telemetry?.inComment ?? config.telemetry?.inComment ?? false;
9
9
  }
10
10
 
11
11
  // The token count beflow reports: prefer an explicit total, else derive it from
package/src/core/setup.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cancel, confirm, isCancel, select, text } from "@clack/prompts";
2
2
 
3
+ import { configDir, configPath } from "../config/paths.ts";
3
4
  import { addProject } from "../config/persist.ts";
4
5
  import type { Project, Registry } from "../config/schema.ts";
5
6
  import type {
@@ -161,7 +162,7 @@ export async function setupProject(projectKey: string, deps: SetupDeps): Promise
161
162
  const ask = deps.askProjectSpec ?? (process.stdin.isTTY ? defaultAskProjectSpec : undefined);
162
163
  if (ask === undefined) {
163
164
  throw new Error(
164
- `beflow: project "${projectKey}" is not in config.json; run setup in an interactive terminal to create it`,
165
+ `beflow: project "${projectKey}" is not in ${configPath()}; run setup in an interactive terminal to create it`,
165
166
  );
166
167
  }
167
168
  const { entry, spec } = await ask({ key: projectKey, tracker: deps.trackerName });
@@ -169,7 +170,7 @@ export async function setupProject(projectKey: string, deps: SetupDeps): Promise
169
170
  if (deps.trackerName === "plane" && trackerProjectId !== undefined) {
170
171
  entry.plane_project_id = trackerProjectId;
171
172
  }
172
- (deps.persist ?? addProject)(deps.dir ?? process.cwd(), projectKey, entry);
173
+ (deps.persist ?? addProject)(deps.dir ?? configDir(), projectKey, entry);
173
174
  // The tracker holds a reference to this same registry object; mutate it in
174
175
  // place so ensureBoard below sees the freshly created project.
175
176
  deps.registry.projects[projectKey] = entry;
package/src/core/sla.ts CHANGED
@@ -8,7 +8,7 @@ export interface SlaThresholds {
8
8
 
9
9
  export function resolveSla(config: Config, registry: Registry, projectKey: string): SlaThresholds {
10
10
  const projectSla = registry.projects[projectKey]?.sla;
11
- const globalSla = config.defaults.sla;
11
+ const globalSla = config.sla;
12
12
  const inReviewMinutes = projectSla?.inReviewMinutes ?? globalSla?.inReviewMinutes;
13
13
  const needsInputMinutes = projectSla?.needsInputMinutes ?? globalSla?.needsInputMinutes;
14
14
  return {
package/src/core/watch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AgentDriver } from "../agent/driver.ts";
2
2
  import type { Config, Registry } from "../config/schema.ts";
3
3
  import type { Issue, Resolved } from "../model/types.ts";
4
+ import { resolvePr } from "../resolve/precedence.ts";
4
5
  import { IssueNotFoundError } from "../trackers/tracker.ts";
5
6
  import type { Tracker } from "../trackers/tracker.ts";
6
7
  import { assembleContinuation, renderContinuation } from "./continuation.ts";
@@ -394,9 +395,11 @@ export async function watchTick(projectKey: string, deps: WatchDeps): Promise<Wa
394
395
  });
395
396
  if (ctx.newComments.length > 0) {
396
397
  await deps.tracker.removeProperty(item, CHANGES_REQUESTED_LABEL);
398
+ const beflowOwnsPr =
399
+ record?.jobKind === "implement" && resolvePr(config, registry, projectKey).owner === "beflow";
397
400
  await runIssue(item.key, AUTONOMOUS_DISPATCH, {
398
401
  ...runIssueDeps(deps, config, registry, log),
399
- continuation: renderContinuation(deps.prompts, ctx),
402
+ continuation: renderContinuation(deps.prompts, ctx, beflowOwnsPr),
400
403
  });
401
404
  log(`beflow: watch ${projectKey} — rework ${item.key}`);
402
405
  return { action: "rework", key: item.key };
@@ -456,8 +459,13 @@ export async function watchTick(projectKey: string, deps: WatchDeps): Promise<Wa
456
459
  }
457
460
 
458
461
  const ctx = await assembleContinuation(deps.tracker, item, { record, since: record.updatedAt });
459
- const ciNote = `The CI checks on this PR are failing (${checks.failing.join(", ") || "unknown checks"}). Investigate the failures, fix them, and update the existing PR (${record.prUrl}). Then emit the report block.`;
460
- const continuation = `${ciNote}\n\n${renderContinuation(deps.prompts, ctx)}`;
462
+ const beflowOwnsPr =
463
+ record.jobKind === "implement" && resolvePr(config, registry, projectKey).owner === "beflow";
464
+ const failingChecks = checks.failing.join(", ") || "unknown checks";
465
+ const ciNote = beflowOwnsPr
466
+ ? `The CI checks on this PR are failing (${failingChecks}). Investigate the failures, fix them, and push your branch (beflow updates the PR). Then emit the report block.`
467
+ : `The CI checks on this PR are failing (${failingChecks}). Investigate the failures, fix them, and update the existing PR (${record.prUrl}). Then emit the report block.`;
468
+ const continuation = `${ciNote}\n\n${renderContinuation(deps.prompts, ctx, beflowOwnsPr)}`;
461
469
  await runIssue(item.key, AUTONOMOUS_DISPATCH, {
462
470
  ...runIssueDeps(deps, config, registry, log),
463
471
  continuation,
@@ -581,9 +589,11 @@ export async function watchTick(projectKey: string, deps: WatchDeps): Promise<Wa
581
589
  if (record?.escalatedAt !== undefined) {
582
590
  await notifyEscalation(deps.notify, item, "resolved", "A human responded; resuming.");
583
591
  }
592
+ const beflowOwnsPr =
593
+ record?.jobKind === "implement" && resolvePr(config, registry, projectKey).owner === "beflow";
584
594
  await runIssue(item.key, AUTONOMOUS_DISPATCH, {
585
595
  ...runIssueDeps(deps, config, registry, log),
586
- continuation: renderContinuation(deps.prompts, ctx),
596
+ continuation: renderContinuation(deps.prompts, ctx, beflowOwnsPr),
587
597
  });
588
598
  log(`beflow: watch ${projectKey} — answered ${item.key}`);
589
599
  return { action: "answered", key: item.key };
@@ -33,3 +33,30 @@ export interface Resolved {
33
33
  repoPath: string;
34
34
  runMode: RunMode;
35
35
  }
36
+
37
+ export type PrOwner = "beflow" | "agent";
38
+
39
+ export interface ResolvedPr {
40
+ owner: PrOwner;
41
+ baseBranch: string;
42
+ }
43
+
44
+ export type PolicyEvaluator = "globs" | "command" | "agentowners" | "off";
45
+
46
+ export type PolicyDecision = "block" | "require_approval" | "allow";
47
+
48
+ export type PolicyOnBlock = "comment";
49
+
50
+ export interface PolicyRule {
51
+ paths?: string[];
52
+ agent?: string;
53
+ decision: PolicyDecision;
54
+ }
55
+
56
+ export interface ResolvedPolicy {
57
+ evaluator: PolicyEvaluator;
58
+ command?: string[];
59
+ rules?: PolicyRule[];
60
+ agentownersPath?: string;
61
+ onBlock: PolicyOnBlock;
62
+ }
@@ -6,4 +6,4 @@ Open PR: {{pr_url}}
6
6
  New input since your last update:
7
7
  {{review_comments}}
8
8
 
9
- Address the input above. If a pull request is already open for this item, UPDATE it — do not open a new one. Then finish the work and emit your report block as usual.
9
+ {{pr_continuation_instruction}} Then finish the work and emit your report block as usual.
@@ -6,8 +6,8 @@ Work through it:
6
6
  - Read and obey that repo's CLAUDE.md — it carries the architecture, conventions, and Definition of Done. The repo holds the bar; follow its existing patterns for structure, naming, error handling, and tests.
7
7
  - Make the change, and back EVERY behavioral change with a test. New behavior without a test does not count as done.
8
8
  - Run the repo's own test and lint commands and paste their REAL output into the report `summary`/`notes`. Fabricating or paraphrasing results is forbidden — paste what actually ran.
9
- - ONLY when the gate is green — tests and lint pass on real output — and every item on your to-do list is marked complete (nothing left pending or in progress), commit, push, and open a pull request with `gh`, then put the PR URL in `prUrl`.
9
+ - ONLY when the gate is green — tests and lint pass on real output — and every item on your to-do list is marked complete (nothing left pending or in progress), {{pr_instruction}}
10
10
 
11
11
  If the task is ambiguous, or turns out far larger than described, STOP: do not guess and do not half-do it. Return status `needs_input` with the specific decisions you need in `questions`.
12
12
 
13
- When you are continuing a returning item (a continuation block precedes this task), read the prior PR and the new input, address it, and UPDATE the existing pull request — do not open a new one.
13
+ When you are continuing a returning item (a continuation block precedes this task), read the prior PR and the new input, address it, and {{pr_continuation_instruction}}
@@ -1,5 +1,5 @@
1
- import type { Project } from "../config/schema.ts";
2
- import type { IssueMeta, JobKind, Resolved, RunMode, StateGroup } from "../model/types.ts";
1
+ import type { Config, Project, Registry } from "../config/schema.ts";
2
+ import type { IssueMeta, JobKind, ResolvedPolicy, ResolvedPr, Resolved, RunMode, StateGroup } from "../model/types.ts";
3
3
  import { autoDetectJobKind } from "./jobkind.ts";
4
4
 
5
5
  export interface ResolveInputs {
@@ -7,7 +7,7 @@ export interface ResolveInputs {
7
7
  meta: IssueMeta;
8
8
  project: Project;
9
9
  // Optional because the resolver is defensive: if nothing global is set it
10
- // falls through to the built-in. (In practice config.defaults always fills these.)
10
+ // falls through to the built-in. (In practice config always fills these.)
11
11
  global: {
12
12
  agent?: string;
13
13
  routing?: { implement?: string; spec?: string; triage?: string };
@@ -39,7 +39,7 @@ export function resolveAgent(inputs: ResolveInputs, jobKind: JobKind): string {
39
39
  inputs.meta.agent,
40
40
  inputs.project.routing?.[jobKind],
41
41
  inputs.global.routing?.[jobKind],
42
- inputs.project.defaults?.agent,
42
+ inputs.project.agent,
43
43
  inputs.global.agent,
44
44
  ) ?? AGENT_BUILTIN
45
45
  );
@@ -47,7 +47,7 @@ export function resolveAgent(inputs: ResolveInputs, jobKind: JobKind): string {
47
47
 
48
48
  export function resolveRunMode(inputs: ResolveInputs): RunMode {
49
49
  return (
50
- cascade(inputs.cli.runMode, inputs.meta.runMode, inputs.project.defaults?.runMode, inputs.global.runMode) ??
50
+ cascade(inputs.cli.runMode, inputs.meta.runMode, inputs.project.runMode, inputs.global.runMode) ??
51
51
  RUN_MODE_BUILTIN
52
52
  );
53
53
  }
@@ -91,6 +91,41 @@ export function resolveJobKind(inputs: ResolveInputs): JobKind {
91
91
  );
92
92
  }
93
93
 
94
+ const PR_OWNER_BUILTIN: ResolvedPr["owner"] = "agent";
95
+ const PR_BASE_BRANCH_BUILTIN = "auto";
96
+ const POLICY_EVALUATOR_BUILTIN: ResolvedPolicy["evaluator"] = "off";
97
+ const POLICY_ON_BLOCK_BUILTIN: ResolvedPolicy["onBlock"] = "comment";
98
+
99
+ /**
100
+ * Project-over-default resolution of PR mechanics. A present `projects.<KEY>.pr`
101
+ * replaces the top-level `pr` wholesale (no field merge); the built-in defaults
102
+ * (`agent` / `auto`) then fill any field the chosen block leaves unset.
103
+ */
104
+ export function resolvePr(config: Config, registry: Registry, projectKey: string): ResolvedPr {
105
+ const block = registry.projects[projectKey]?.pr ?? config.pr;
106
+ return {
107
+ owner: block?.owner ?? PR_OWNER_BUILTIN,
108
+ baseBranch: block?.baseBranch ?? PR_BASE_BRANCH_BUILTIN,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Project-over-default resolution of the post-run policy gate. A present
114
+ * `projects.<KEY>.policy` replaces the top-level `policy` wholesale (no field
115
+ * merge); the built-in defaults (evaluator `off`, onBlock `comment`) then fill
116
+ * any field the chosen block leaves unset.
117
+ */
118
+ export function resolvePolicy(config: Config, registry: Registry, projectKey: string): ResolvedPolicy {
119
+ const block = registry.projects[projectKey]?.policy ?? config.policy;
120
+ return {
121
+ evaluator: block?.evaluator ?? POLICY_EVALUATOR_BUILTIN,
122
+ command: block?.command,
123
+ rules: block?.rules,
124
+ agentownersPath: block?.agentownersPath,
125
+ onBlock: block?.onBlock ?? POLICY_ON_BLOCK_BUILTIN,
126
+ };
127
+ }
128
+
94
129
  export function resolve(inputs: ResolveInputs): Resolved {
95
130
  const { repo, repoPath } = resolveRepo(inputs);
96
131
  const jobKind = resolveJobKind(inputs);