@nanobpm/nano-workforce 0.209.1 → 0.210.1

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 (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +11 -1
  3. package/app/behindBaseRemedy.test.ts +1 -1
  4. package/app/convergeAdversarial.test.ts +152 -0
  5. package/app/failingChecksSupersede.test.ts +288 -1
  6. package/app/feature.test.ts +31 -0
  7. package/app/feature.ts +12 -0
  8. package/app/github.test.ts +200 -10
  9. package/app/github.ts +365 -59
  10. package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
  11. package/app/mergeLoopBehaviour.test.ts +48 -0
  12. package/app/pullRequestReadModel.test.ts +1 -1
  13. package/app/readiness.test.ts +90 -1
  14. package/app/readiness.ts +81 -3
  15. package/app/service.test.ts +125 -1
  16. package/app/service.ts +63 -1
  17. package/build-info.json +4 -4
  18. package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
  19. package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
  20. package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
  21. package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
  22. package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
  23. package/docs/agent-guide.md +1 -0
  24. package/openapi.yaml +24 -0
  25. package/operations/listActivePrs.test.ts +17 -0
  26. package/operations/startAndMessage.test.ts +80 -0
  27. package/operations/startConvergenceLoop.ts +13 -1
  28. package/package.json +1 -1
  29. package/pages/home.page.json +7 -2
  30. package/resources/processes/convergence-loop.bpmn +8 -2
  31. package/resources/processes/feature.bpmn +1 -0
  32. package/resources/processes/merge-loop.bpmn +6 -2
  33. package/workers/converge-feature/worker.ts +10 -1
  34. package/workers/landed-probe/worker.test.ts +1 -1
  35. package/workers/merge-stall-probe/worker.test.ts +2 -2
  36. package/workers/reconverge/worker.test.ts +50 -0
  37. package/workers/reconverge/worker.ts +7 -0
@@ -554,6 +554,95 @@ test("#856 parsePrTarget agrees with the canonical parsePr on every PR shape (no
554
554
  }
555
555
  });
556
556
 
557
+ // ── PR #957 review round 3: the readiness `pr` path normalises events too ────────────────────────
558
+ //
559
+ // The `gh pr view` rollup omits `event`, so before this fix the readiness path grouped by name alone
560
+ // and a green `push` run hid a red required `pull_request` run of the same name — `checks-green`
561
+ // reported ready while the PR run was red. `probeOnce` now enriches the rollup through the injected
562
+ // executor (shared transport logic with the merge loop) before parsing.
563
+
564
+ test("#957 probeOnce pr checks-green: a green push run never hides a red pull_request run of the same name", async () => {
565
+ const head = "5fbd262a28987d647621da324bf1a7855a8da500";
566
+ // The `gh pr view` payload: two same-named runs, NO event on either (the CLI exporter omits it).
567
+ const prView = {
568
+ state: "OPEN",
569
+ mergedAt: null,
570
+ mergeStateStatus: "CLEAN",
571
+ isDraft: false,
572
+ headRefOid: head,
573
+ mergeCommit: null,
574
+ statusCheckRollup: [
575
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
576
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
577
+ ],
578
+ };
579
+ // The GraphQL event read, keyed by the SAME head, joining by (name, startedAt).
580
+ const events = {
581
+ data: {
582
+ repository: {
583
+ pullRequest: {
584
+ commits: {
585
+ nodes: [
586
+ {
587
+ commit: {
588
+ oid: head,
589
+ statusCheckRollup: {
590
+ contexts: {
591
+ nodes: [
592
+ { __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
593
+ { __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
594
+ ],
595
+ pageInfo: { hasNextPage: false, endCursor: null },
596
+ },
597
+ },
598
+ },
599
+ },
600
+ ],
601
+ },
602
+ },
603
+ },
604
+ },
605
+ };
606
+ const exec: ProbeExec = {
607
+ async httpGet() {
608
+ return { status: 0, body: "" };
609
+ },
610
+ async run(command) {
611
+ // Route the two reads by shape: the GraphQL call carries `api graphql`, the rollup read `pr view`.
612
+ if (command.includes("api") && command.includes("graphql")) return { code: 0, stdout: JSON.stringify(events), stderr: "" };
613
+ return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
614
+ },
615
+ };
616
+ const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
617
+ const res = await probeOnce(p, exec, {});
618
+ assertEquals(res.ready, false, "the red pull_request run survives event normalisation — checks-green is NOT ready");
619
+ assertStringIncludes(res.detail, "failing");
620
+ });
621
+
622
+ test("#957 probeOnce pr checks-green: an event-enrichment failure degrades to name-only, never throws", async () => {
623
+ const prView = {
624
+ state: "OPEN",
625
+ mergedAt: null,
626
+ mergeStateStatus: "CLEAN",
627
+ isDraft: false,
628
+ headRefOid: "abc",
629
+ mergeCommit: null,
630
+ statusCheckRollup: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" }],
631
+ };
632
+ const exec: ProbeExec = {
633
+ async httpGet() {
634
+ return { status: 0, body: "" };
635
+ },
636
+ async run(command) {
637
+ if (command.includes("api") && command.includes("graphql")) return { code: 1, stdout: "", stderr: "boom" }; // the event read fails
638
+ return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
639
+ },
640
+ };
641
+ const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
642
+ const res = await probeOnce(p, exec, {});
643
+ assertEquals(res.ready, true, "a failed event read leaves the rollup event-less — a single green run is still green");
644
+ });
645
+
557
646
  // ── #857: an epic target is a CANONICAL planKey (`owner/repo#N`) — never a URL ────────────────────
558
647
  // The epic probe keys off the durable lineage root (`owner/repo#N`, `parseIssue`'s `planKey` shape).
559
648
  // Delegating its validation to `parsePr` (whose grammar includes GitHub PR URLs) let a URL pass the
@@ -780,7 +869,7 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
780
869
  totalChecks: 0,
781
870
  presentCheckNames: [],
782
871
  pendingCheckNames: [],
783
- checkConclusions: {},
872
+ checkGateConclusions: {},
784
873
  isDraft: false,
785
874
  headRefOid: "abc123",
786
875
  mergedSha: null,
package/app/readiness.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // any credential is read at execution time from the typed env-contract (`credentialEnv` names a
19
19
  // declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
20
20
  import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
21
- import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
21
+ import { allCheckNames, applyRollupEvents, CHECK_EVENTS_QUERY, checkGateConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
22
22
  import { parsePr } from "./prParse.ts";
23
23
  import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
24
24
 
@@ -764,7 +764,7 @@ export function parsePrView(payload: unknown): PrObservation {
764
764
  totalChecks: rollup.length,
765
765
  presentCheckNames: allCheckNames(rollup),
766
766
  pendingCheckNames: pending,
767
- checkConclusions: checkConclusions(rollup),
767
+ checkGateConclusions: checkGateConclusions(rollup),
768
768
  isDraft: j.isDraft === true,
769
769
  headRefOid: str(j.headRefOid).trim() || null,
770
770
  mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
@@ -857,6 +857,76 @@ export function prViewCommand(repo: string, number: string): string {
857
857
  return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
858
858
  }
859
859
 
860
+ /** The GraphQL page query backing {@link enrichPrViewEvents}, as a single-quoted shell argument.
861
+ * Mirrors the merge loop's enrichment (`enrichRollupEvents` in `app/github.ts`) but runs through the
862
+ * probe's injected {@link ProbeExec} so the readiness path stays host-agnostic and unit-testable. */
863
+ function prCheckEventsQuery(): string {
864
+ return CHECK_EVENTS_QUERY;
865
+ }
866
+
867
+ /** Enrich a `pr`-probe rollup with each run's triggering event, through the probe's injected
868
+ * executor. The `gh pr view --json statusCheckRollup` read omits `event`, so without this the
869
+ * readiness path groups by name alone and a green `push` run hides a red required `pull_request` run
870
+ * of the same name — `checks-green` then reports ready while the PR run is red (PR #957 review).
871
+ * Pages `statusCheckRollup.contexts` to exhaustion and pins the read to the rollup's head SHA
872
+ * (aborting the stamp when the head moved between the two reads), exactly like the merge loop's
873
+ * `enrichRollupEvents`. Never throws: any transport/parse failure leaves the rollup event-less. */
874
+ async function enrichPrViewEvents(
875
+ exec: ProbeExec,
876
+ env: Record<string, string | undefined>,
877
+ repo: string,
878
+ number: string,
879
+ payload: Record<string, unknown>,
880
+ ): Promise<void> {
881
+ const j = payload;
882
+ if (!Array.isArray(j.statusCheckRollup) || j.statusCheckRollup.length === 0) return;
883
+ const rollup = j.statusCheckRollup;
884
+ const headSha = typeof j.headRefOid === "string" ? j.headRefOid : "";
885
+ const [owner, name] = repo.split("/");
886
+ const nodes: unknown[] = [];
887
+ let after: string | null = null;
888
+ for (;;) {
889
+ const args = [
890
+ "api",
891
+ "graphql",
892
+ "-f",
893
+ `query=${prCheckEventsQuery()}`,
894
+ "-f",
895
+ `o=${owner}`,
896
+ "-f",
897
+ `r=${name}`,
898
+ "-F",
899
+ `n=${number}`,
900
+ ];
901
+ if (after !== null) args.push("-f", `after=${after}`);
902
+ const command = `gh ${args.map(shellQuote).join(" ")}`;
903
+ const out = await exec.run(command, env);
904
+ if (out.code !== 0) return; // a transport hiccup leaves the rollup event-less (name-only gates)
905
+ const page = parseJson(out.stdout);
906
+ if (!isRecord(page)) return;
907
+ const data = isRecord(page.data) ? page.data : undefined;
908
+ const repository = data && isRecord(data.repository) ? data.repository : undefined;
909
+ const pullRequest = repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
910
+ const commits = pullRequest && isRecord(pullRequest.commits) ? pullRequest.commits : undefined;
911
+ const firstCommit = commits && Array.isArray(commits.nodes) ? commits.nodes[0] : undefined;
912
+ const commit = isRecord(firstCommit) && isRecord(firstCommit.commit) ? firstCommit.commit : undefined;
913
+ if (!isRecord(commit)) return;
914
+ // Head-pin: the rollup came from the earlier `gh pr view`; if the head moved since, these
915
+ // contexts belong to a different head — stamping them would join events across heads.
916
+ const oid = typeof commit.oid === "string" ? commit.oid : "";
917
+ if (headSha !== "" && oid !== "" && oid !== headSha) return;
918
+ const rollupObj = isRecord(commit.statusCheckRollup) ? commit.statusCheckRollup : undefined;
919
+ const contexts = rollupObj && isRecord(rollupObj.contexts) ? rollupObj.contexts : undefined;
920
+ if (contexts === undefined) return;
921
+ if (Array.isArray(contexts.nodes)) nodes.push(...contexts.nodes);
922
+ const pageInfo = isRecord(contexts.pageInfo) ? contexts.pageInfo : undefined;
923
+ const endCursor = pageInfo && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : "";
924
+ if (pageInfo?.hasNextPage !== true || endCursor === "") break;
925
+ after = endCursor;
926
+ }
927
+ applyRollupEvents(rollup, nodes);
928
+ }
929
+
860
930
  // ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ──────
861
931
 
862
932
  /** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic
@@ -974,7 +1044,15 @@ export async function probeOnce(
974
1044
  if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
975
1045
  const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
976
1046
  if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
977
- return matchPr(probe.match, parsePrView(parseJson(out.stdout)));
1047
+ const payload = parseJson(out.stdout);
1048
+ if (!isRecord(payload)) return matchPr(probe.match, parsePrView(payload));
1049
+ // Normalise each run's triggering event BEFORE parsing (PR #957 review): the `gh pr view`
1050
+ // rollup omits `event`, so an un-enriched read groups by name alone and a green `push` run
1051
+ // hides a red required `pull_request` run of the same name — `checks-green` would report
1052
+ // ready while the PR run is red. Shared transport logic with the merge loop, over the
1053
+ // injected executor.
1054
+ await enrichPrViewEvents(exec, env, ref.repo, ref.number, payload);
1055
+ return matchPr(probe.match, parsePrView(payload));
978
1056
  }
979
1057
  case "epic": {
980
1058
  // "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
@@ -6,7 +6,7 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
- import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
9
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
10
10
  import { memDataFor } from "../test/worldDb.ts";
11
11
  import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  import { DurableResumeRegistry } from "./durableResume.ts";
@@ -1627,6 +1627,130 @@ test("startMerge stamps the merge-stage policy on the row (issue #947)", async (
1627
1627
  });
1628
1628
  });
1629
1629
 
1630
+ // ── Issue #956 review: the operator-steering TEXT is durable and survives the merge handoff ──────
1631
+ // Copilot threads 4547353262 / 4547353302: the #956 projection carried only the boolean
1632
+ // `custom_instructions` flag — the TEXT lived solely on the convergence instance's variables, so
1633
+ // every re-enrollment path that re-enters `submitPr` without it (merge→reconvergence handoff,
1634
+ // `handoffWaitingMergeToConvergence`, the poller's feature-delivery re-enroll) silently dropped the
1635
+ // steering and reset the flag. The text is now persisted on the row (`custom_instructions_text`)
1636
+ // and re-seeded by `startMerge`, so a steered run stays steered across the whole lifecycle.
1637
+
1638
+ test("submitPr persists the instruction TEXT on the row (insert path) (#956 review)", async () => {
1639
+ await withGithubOff(async () => {
1640
+ const { data, engine, stores } = captureRoot();
1641
+ await submitPr(
1642
+ data,
1643
+ engine,
1644
+ { repo: "owner/repo", number: 31, url: "https://github.com/owner/repo/pull/31", prKey: "owner/repo#31" },
1645
+ [],
1646
+ 20,
1647
+ false,
1648
+ null,
1649
+ false,
1650
+ false,
1651
+ undefined,
1652
+ null,
1653
+ null,
1654
+ " prefer Deno; keep the diff small ",
1655
+ );
1656
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1657
+ assertEquals(pr.custom_instructions, 1, "the presence flag is stamped");
1658
+ assertEquals(pr.custom_instructions_text, "prefer Deno; keep the diff small", "the TRIMMED text is durable on the row");
1659
+ });
1660
+ });
1661
+
1662
+ test("submitPr's run-scoped reopen carries a re-supplied text (and a handoff re-supplying the row's text keeps it) (#956 review)", async () => {
1663
+ await withGithubOff(async () => {
1664
+ const { data, engine, stores, vars } = captureRoot();
1665
+ const parsed = { repo: "owner/repo", number: 32, url: "https://github.com/owner/repo/pull/32", prKey: "owner/repo#32" };
1666
+ await submitPr(data, engine, parsed, [], 20, false, null, false, false, undefined, null, null, "steer me");
1667
+ const firstVars = vars();
1668
+ assertEquals(firstVars.customInstructions, "steer me", "the first submission seeds the steering");
1669
+ // A merge→reconvergence handoff re-enters `submitPr` on the LIVE row, and by then the merge
1670
+ // loop OWNS it (`startMerge` re-points `process_key` at the merge instance). Simulate that
1671
+ // owner: without this, `handoffFrom: "PI-MERGE"` fails the live-owner gate and the call
1672
+ // short-circuits as `alreadyRunning` — never exercising the reopen this test exists for.
1673
+ const row = stores.pull_requests.rows[0] as Record<string, unknown>;
1674
+ assertEquals(row.process_key, "PI-1", "the first submission owns the row");
1675
+ row.process_key = "PI-MERGE";
1676
+ const handedBack = await submitPr(
1677
+ data,
1678
+ engine,
1679
+ parsed,
1680
+ [],
1681
+ 20,
1682
+ false,
1683
+ null,
1684
+ false,
1685
+ true,
1686
+ undefined,
1687
+ "PI-MERGE",
1688
+ null,
1689
+ row.custom_instructions_text as string,
1690
+ );
1691
+ assertEquals(handedBack, { prKey: "owner/repo#32", processKey: "PI-1" }, "the owning merge loop's hand-back mints a SUCCESSOR, not alreadyRunning");
1692
+ const after = stores.pull_requests.rows[0] as Record<string, unknown>;
1693
+ assertEquals(after.custom_instructions, 1, "the flag survives the handoff reopen");
1694
+ assertEquals(after.custom_instructions_text, "steer me", "the text survives the handoff reopen");
1695
+ const successorVars = vars();
1696
+ assertEquals(successorVars.customInstructions, "steer me", "the fresh convergence instance is re-seeded with the steering");
1697
+ assert(successorVars !== firstVars, "a successor instance was actually created (not the first submission's captured variables)");
1698
+ assertEquals(after.process_key, "PI-1", "the successor instance now owns the row (the fake engine mints PI-1 per capture)");
1699
+ });
1700
+ });
1701
+
1702
+ test("startMerge re-seeds the merge-loop instance with the row's custom instructions (#956 review)", async () => {
1703
+ await withGithubOff(async () => {
1704
+ const { data, engine, stores, vars } = captureRoot();
1705
+ (stores.pull_requests.rows as unknown[]).push({
1706
+ pr_key: "owner/repo#33",
1707
+ repo: "owner/repo",
1708
+ number: 33,
1709
+ url: "https://github.com/owner/repo/pull/33",
1710
+ status: "converged",
1711
+ current_round: 2,
1712
+ process_key: "PI-CONV",
1713
+ merge_requested: 1,
1714
+ merge_human_approval: 1,
1715
+ custom_instructions: 1,
1716
+ custom_instructions_text: "commit as josh.wulf@camunda.com",
1717
+ });
1718
+ await startMerge(data, engine, {
1719
+ repo: "owner/repo",
1720
+ number: 33,
1721
+ url: "https://github.com/owner/repo/pull/33",
1722
+ prKey: "owner/repo#33",
1723
+ round: 2,
1724
+ });
1725
+ assertEquals(vars()?.customInstructions, "commit as josh.wulf@camunda.com", "the merge stage carries the steering (and a reconverge can re-read it)");
1726
+ });
1727
+ });
1728
+
1729
+ test("startMerge seeds customInstructions null on an unsteered row (prompts stay byte-identical)", async () => {
1730
+ await withGithubOff(async () => {
1731
+ const { data, engine, stores, vars } = captureRoot();
1732
+ (stores.pull_requests.rows as unknown[]).push({
1733
+ pr_key: "owner/repo#34",
1734
+ repo: "owner/repo",
1735
+ number: 34,
1736
+ url: "https://github.com/owner/repo/pull/34",
1737
+ status: "converged",
1738
+ current_round: 1,
1739
+ process_key: "PI-CONV",
1740
+ custom_instructions: 0,
1741
+ custom_instructions_text: null,
1742
+ });
1743
+ await startMerge(data, engine, {
1744
+ repo: "owner/repo",
1745
+ number: 34,
1746
+ url: "https://github.com/owner/repo/pull/34",
1747
+ prKey: "owner/repo#34",
1748
+ round: 1,
1749
+ });
1750
+ assertEquals(vars()?.customInstructions ?? null, null, "no steering → null, never a blank string");
1751
+ });
1752
+ });
1753
+
1630
1754
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1631
1755
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1632
1756
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
package/app/service.ts CHANGED
@@ -443,6 +443,17 @@ export interface PullRequest {
443
443
  // rows fall back to the #937 batched engine read. Run-scoped: reset by the atomic reopen.
444
444
  merge_requested?: number | null;
445
445
  merge_human_approval?: number | null;
446
+ // Operator-steering projection (134_pull_requests_custom_instructions.sql, issue #956): 1 ⇒ this
447
+ // run's converge agents carry `customInstructions` (stamped by `submitPr` at submit/reopen, run-
448
+ // scoped like the merge policy), so `/status` shows the loop is steered with no engine read. NULL
449
+ // marks a row written before the projection existed; it reads as false.
450
+ custom_instructions?: number | null;
451
+ // The steering TEXT itself (136_pull_requests_custom_instructions_text.sql, issue #956 review):
452
+ // the boolean above can't re-seed a loop, so the trimmed instructions are durable on the row and
453
+ // every re-enrollment path (merge→reconvergence `pr.reconverge`, `handoffWaitingMergeToConvergence`,
454
+ // the poller's feature-delivery re-enroll) re-supplies them, and `startMerge` re-seeds the merge
455
+ // instance. Run-scoped with the flag; NULL when the run carries no steering.
456
+ custom_instructions_text?: string | null;
446
457
  }
447
458
 
448
459
  interface PrDependency {
@@ -669,9 +680,10 @@ export async function submitPr(
669
680
  autoMerge?: boolean,
670
681
  handoffFrom: string | null = null,
671
682
  headShaOverride: string | null = null,
683
+ customInstructions: string | null = null,
672
684
  ) {
673
685
  return withPrSubmitLock(parsed.prKey, () =>
674
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
686
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride, customInstructions),
675
687
  );
676
688
  }
677
689
 
@@ -688,6 +700,7 @@ async function submitPrCritical(
688
700
  autoMerge?: boolean,
689
701
  handoffFrom: string | null = null,
690
702
  headShaOverride: string | null = null,
703
+ customInstructions: string | null = null,
691
704
  ) {
692
705
  // Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
693
706
  // the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
@@ -701,6 +714,13 @@ async function submitPrCritical(
701
714
  // merge-loop — its own predicate, `AUTO_MERGE && merge intent on`. A run that won't merge
702
715
  // (review-only, or the global switch off) must not park for a pointless approval.
703
716
  const gated = humanApproval && mergeIntent && AUTO_MERGE;
717
+ // Operator free-text steering for the converge agents (issue #956), mirrored from the feature path
718
+ // (feature.ts): blank/absent → null so the agent tasks' `appendPrompt` FEEL skips the "## Operator
719
+ // custom instructions" block rather than appending an empty heading — an omitted/blank value leaves
720
+ // every prompt byte-identical to no-instructions.
721
+ const instructions = typeof customInstructions === "string" && customInstructions.trim() !== ""
722
+ ? customInstructions.trim()
723
+ : null;
704
724
  const table = prs(data);
705
725
  const existing = await table.get(parsed.prKey);
706
726
  // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
@@ -875,6 +895,11 @@ async function submitPrCritical(
875
895
  // Mirror the engine seed EXACTLY (PR #950 review): an ungated loop is seeded
876
896
  // `humanApproval: false` (decided — no gate), a gated one `true` (outstanding).
877
897
  merge_human_approval: gated ? 1 : 0,
898
+ // Operator-steering projection (issue #956): 1 ⇒ this run's converge agents carry custom
899
+ // instructions, so `/status` can show the loop is steered with no engine read. The TEXT is
900
+ // durable alongside (issue #956 review) so a handoff/re-enrollment can re-supply it.
901
+ custom_instructions: instructions != null ? 1 : 0,
902
+ custom_instructions_text: instructions,
878
903
  created_at: ts,
879
904
  updated_at: ts,
880
905
  });
@@ -943,6 +968,11 @@ async function submitPrCritical(
943
968
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
944
969
  abandonUrl: abUrl,
945
970
  abandonBrief: renderAbandonBrief(abUrl),
971
+ // Optional operator steering (issue #956): appended to EVERY converge round's agent prompt
972
+ // (review-round + adversarial-review) via their `appendPrompt` FEEL, under the same
973
+ // "## Operator custom instructions" block the implement cell uses. Null when none was supplied;
974
+ // persists on the instance, so reconverge re-dispatches and later rounds keep carrying it.
975
+ customInstructions: instructions,
946
976
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
947
977
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
948
978
  // unresolved head (`{}`) leaves the other vars untouched.
@@ -981,6 +1011,9 @@ async function submitPrCritical(
981
1011
  // authoritative (`stale: false`) in `/status`.
982
1012
  merge_requested: existing.merge_requested ?? null,
983
1013
  merge_human_approval: existing.merge_human_approval ?? null,
1014
+ // The operator-steering projection (issue #956) rolls back with the same ownership.
1015
+ custom_instructions: existing.custom_instructions ?? null,
1016
+ custom_instructions_text: existing.custom_instructions_text ?? null,
984
1017
  }
985
1018
  : null;
986
1019
  try {
@@ -1043,6 +1076,9 @@ async function submitPrCritical(
1043
1076
  // the fresh run's intent wins, and the approval state mirrors the fresh loop's `humanApproval` seed.
1044
1077
  merge_requested: mergeIntent ? 1 : 0,
1045
1078
  merge_human_approval: gated ? 1 : 0,
1079
+ // Operator-steering projection (issue #956): run-scoped too — the fresh run's steering wins.
1080
+ custom_instructions: instructions != null ? 1 : 0,
1081
+ custom_instructions_text: instructions,
1046
1082
  // Publish the fresh run's identity in the SAME write as the status flip (may be null if the
1047
1083
  // instance could not be created — a phantom row a retry re-enrolls, as on first submit).
1048
1084
  process_key: processKey,
@@ -1114,6 +1150,14 @@ export async function startMerge(
1114
1150
  // self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
1115
1151
  // column.
1116
1152
  const rootRequestKey = existing?.root_request_key ?? null;
1153
+ // Operator steering (issue #956 review): re-seed the merge-loop instance with the run's durable
1154
+ // custom instructions. The convergence instance's own copy dies with it at the merge handoff, and
1155
+ // the merge stage's `pr.reconverge` re-entry is one of the paths that must not drop the steering —
1156
+ // carrying it here keeps the merge-stage agents (and any reconverge fallback) steered.
1157
+ const customInstructions =
1158
+ typeof existing?.custom_instructions_text === "string" && existing.custom_instructions_text.trim() !== ""
1159
+ ? existing.custom_instructions_text.trim()
1160
+ : null;
1117
1161
  const abUrl = abandonUrl(abandonToken);
1118
1162
  // Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
1119
1163
  // out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
@@ -1163,6 +1207,9 @@ export async function startMerge(
1163
1207
  reviewRequest: null,
1164
1208
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1165
1209
  rootRequestKey,
1210
+ // Operator steering (issue #956 review): the run's durable custom instructions, so the merge
1211
+ // stage's agents and a `pr.reconverge` re-entry keep the steering the convergence loop ran with.
1212
+ customInstructions,
1166
1213
  // Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
1167
1214
  // the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
1168
1215
  // the merge-loop process reads none of them.
@@ -1270,6 +1317,10 @@ export interface ActivePr {
1270
1317
  /** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
1271
1318
  * eligible for steward auto-resolution. Shown in `/status` per PR. */
1272
1319
  autoResolveEscalations: boolean;
1320
+ /** Whether this run's converge agents carry operator custom instructions (issue #956), served
1321
+ * from the durable `custom_instructions` row projection — the operator can see a loop is being
1322
+ * steered with no engine read. */
1323
+ hasCustomInstructions: boolean;
1273
1324
  /** The effective merge policy (issue #916): what the run intends (`requested`), what the
1274
1325
  * deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
1275
1326
  merge: MergePolicy;
@@ -1377,6 +1428,7 @@ export async function activePrs(
1377
1428
  activeWorker: p.active_worker ?? null,
1378
1429
  leaseUntil: p.lease_until ?? null,
1379
1430
  autoResolveEscalations: p.auto_resolve_escalations === 1,
1431
+ hasCustomInstructions: p.custom_instructions === 1,
1380
1432
  merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1381
1433
  incident,
1382
1434
  terminationReason: p.termination_reason ?? null,
@@ -2326,6 +2378,10 @@ export async function handoffWaitingMergeToConvergence(
2326
2378
  pr.auto_resolve_escalations === 1,
2327
2379
  undefined,
2328
2380
  mergeKey,
2381
+ // Operator steering (issue #956 review): re-supply the run's durable instruction text so the
2382
+ // merge→convergence handoff never starts an unsteered replacement loop.
2383
+ null,
2384
+ typeof pr.custom_instructions_text === "string" ? pr.custom_instructions_text : null,
2329
2385
  );
2330
2386
  const successor = "processKey" in res ? res.processKey : null;
2331
2387
  if (successor == null) {
@@ -3870,6 +3926,12 @@ export async function pollFeatureDelivery(
3870
3926
  // Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
3871
3927
  // durable `auto_merge` flag (mirrors `converge-feature`).
3872
3928
  run.auto_merge === 1,
3929
+ // Operator steering (issue #956 review): the run's durable instruction text, so a
3930
+ // poller-driven (re-)enrollment — which has no job variables — recovers the same
3931
+ // steering the `converge-feature` worker would have forwarded.
3932
+ null,
3933
+ null,
3934
+ typeof run.custom_instructions === "string" ? run.custom_instructions : null,
3873
3935
  );
3874
3936
  }
3875
3937
  }
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.209.1",
3
- "gitSha": "5b70c1733cd919fcc8f43ca118a863d3714030ea",
4
- "gitTag": "v0.209.1",
5
- "builtAt": "2026-11-12T07:16:21.905Z"
2
+ "version": "0.210.1",
3
+ "gitSha": "e2c4e966b566ce8b4bdc19e84bceb130909a981c",
4
+ "gitTag": "v0.210.1",
5
+ "builtAt": "2026-11-12T12:00:58.232Z"
6
6
  }
@@ -0,0 +1,8 @@
1
+ -- Durable operator-steering projection for /status (issue #956). `startConvergenceLoop` accepts an
2
+ -- optional `customInstructions` string that is appended to EVERY converge round's agent prompt
3
+ -- (review-round + adversarial-review) via their `appendPrompt` FEEL. So the operator can SEE that a
4
+ -- loop is being steered without an engine read, the writer that already knows — `submitPr`
5
+ -- (submit/reopen) — stamps `custom_instructions` (1|0) on the row, and `/status` serves it as
6
+ -- `hasCustomInstructions`. Run-scoped: the atomic reopen resets it to the fresh run's steering.
7
+ -- `custom_instructions` is NULL on every pre-#956 row; those legacy rows read as false.
8
+ ALTER TABLE pull_requests ADD COLUMN custom_instructions INTEGER;
@@ -0,0 +1,52 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 133 with `custom_instructions` (134, issue #956) added; the derived columns are emitted
4
+ -- VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each
5
+ -- file). Numbered after 134.
6
+
7
+ DROP VIEW IF EXISTS pull_requests_read_model;
8
+
9
+ CREATE VIEW pull_requests_read_model AS
10
+ SELECT
11
+ pr.pr_key AS pr_key,
12
+ pr.repo AS repo,
13
+ pr.number AS number,
14
+ pr.url AS url,
15
+ pr.title AS title,
16
+ COALESCE(pr.derived_status, pr.status) AS status,
17
+ pr.current_round AS current_round,
18
+ pr.process_key AS process_key,
19
+ pr.waiting_since AS waiting_since,
20
+ pr.last_review_id AS last_review_id,
21
+ pr.outcome AS outcome,
22
+ pr.created_at AS created_at,
23
+ pr.updated_at AS updated_at,
24
+ pr.converged_at AS converged_at,
25
+ pr.merged_at AS merged_at,
26
+ pr.active_worker AS active_worker,
27
+ pr.lease_until AS lease_until,
28
+ pr.last_nudge_at AS last_nudge_at,
29
+ pr.fresh_head_run_head AS fresh_head_run_head,
30
+ pr.abandon_token AS abandon_token,
31
+ pr.incident_key AS incident_key,
32
+ pr.incident_type AS incident_type,
33
+ pr.incident_since AS incident_since,
34
+ pr.incident_message AS incident_message,
35
+ pr.last_round_head AS last_round_head,
36
+ pr.last_progress_job_key AS last_progress_job_key,
37
+ pr.last_progress_result AS last_progress_result,
38
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
39
+ pr.churn_escalated_round AS churn_escalated_round,
40
+ pr.root_request_key AS root_request_key,
41
+ pr.epic_phase_label AS epic_phase_label,
42
+ pr.acknowledged_at AS acknowledged_at,
43
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
44
+ pr.termination_reason AS termination_reason,
45
+ pr.termination_process_key AS termination_process_key,
46
+ pr.retire_pending_key AS retire_pending_key,
47
+ pr.merge_requested AS merge_requested,
48
+ pr.merge_human_approval AS merge_human_approval,
49
+ pr.custom_instructions AS custom_instructions,
50
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
51
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
52
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,11 @@
1
+ -- Durable operator-steering TEXT for the convergence lifecycle (issue #956 review, Copilot threads
2
+ -- 4547353262 / 4547353302). Migration 134 added only the boolean `custom_instructions` PROJECTION
3
+ -- (1 ⇒ steered) so `/status` needs no engine read — but the TEXT itself lived solely on the
4
+ -- convergence instance's variables. Every path that RE-ENTERS `submitPr` without the text
5
+ -- (the merge→reconvergence `pr.reconverge` handoff, `handoffWaitingMergeToConvergence`, the
6
+ -- poller's feature-delivery re-enroll) therefore started a replacement loop that ran UNSTEERED and
7
+ -- reset the flag to 0. Persist the trimmed text on the row so any handoff can re-supply it, and so
8
+ -- `startMerge` can re-seed the merge-loop instance (whose `pr.reconverge` is one of those paths).
9
+ -- Run-scoped exactly like `custom_instructions`: the atomic reopen rewrites it to the fresh run's
10
+ -- steering. NULL on every pre-existing row; those read as "no instructions".
11
+ ALTER TABLE pull_requests ADD COLUMN custom_instructions_text TEXT;