@nanobpm/nano-workforce 0.73.0 → 0.74.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.74.0](https://github.com/nanobpm/nano-workforce/compare/v0.73.1...v0.74.0) (2026-08-16)
2
+
3
+
4
+ ### Features
5
+
6
+ * **feature:** thread optional custom instructions to the implementation agent ([#247](https://github.com/nanobpm/nano-workforce/issues/247)) ([8b65495](https://github.com/nanobpm/nano-workforce/commit/8b65495a739b01560937fa108ed4def16c3ffd85))
7
+
8
+ ## [0.73.1](https://github.com/nanobpm/nano-workforce/compare/v0.73.0...v0.73.1) (2026-08-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge:** treat a Depends-on ref that is not a PR as non-blocking ([#246](https://github.com/nanobpm/nano-workforce/issues/246)) ([c800f81](https://github.com/nanobpm/nano-workforce/commit/c800f81b02b3a9a787d79552f4e5eef9971e3edf)), closes [Magikcraft/nano-bpm#806](https://github.com/Magikcraft/nano-bpm/issues/806)
14
+
1
15
  # [0.73.0](https://github.com/nanobpm/nano-workforce/compare/v0.72.1...v0.73.0) (2026-08-15)
2
16
 
3
17
 
@@ -114,6 +114,42 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
114
114
  assertEquals(v.status, null);
115
115
  });
116
116
 
117
+ test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => {
118
+ let captured: any = null;
119
+ const engine = {
120
+ createInstance: (req: any) => {
121
+ captured = req;
122
+ return Promise.resolve({ processInstanceKey: "PI-3" });
123
+ },
124
+ } as any;
125
+ await startFeature(
126
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
127
+ engine,
128
+ PARSED,
129
+ "main",
130
+ false,
131
+ false,
132
+ " prefer Deno; keep the diff small ",
133
+ );
134
+ assertEquals(captured.variables.customInstructions, "prefer Deno; keep the diff small");
135
+ });
136
+
137
+ test("startFeature: blank/absent custom instructions are seeded as null", async () => {
138
+ let captured: any = null;
139
+ const engine = {
140
+ createInstance: (req: any) => {
141
+ captured = req;
142
+ return Promise.resolve({ processInstanceKey: "PI-4" });
143
+ },
144
+ } as any;
145
+ // Absent (default arg) → null.
146
+ await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false);
147
+ assertEquals(captured.variables.customInstructions, null);
148
+ // Whitespace-only → null (so the appendPrompt FEEL skips the block instead of appending an empty heading).
149
+ await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, " ");
150
+ assertEquals(captured.variables.customInstructions, null);
151
+ });
152
+
117
153
  test("startFeature: an already-running run short-circuits (no new instance)", async () => {
118
154
  const stores = {
119
155
  feature_runs: {
package/app/feature.ts CHANGED
@@ -267,7 +267,14 @@ export async function startFeature(
267
267
  baseBranch: string,
268
268
  converge: boolean,
269
269
  autoMerge: boolean,
270
+ customInstructions: string | null = null,
270
271
  ) {
272
+ // Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
273
+ // null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
274
+ // rather than appending an empty "Operator custom instructions" heading.
275
+ const instructions = typeof customInstructions === "string" && customInstructions.trim() !== ""
276
+ ? customInstructions.trim()
277
+ : null;
271
278
  const table = featureRuns(data);
272
279
  const existing = await table.get(parsed.planKey);
273
280
  if (existing && !FEATURE_TERMINAL_STATUSES.includes(existing.status)) {
@@ -356,6 +363,10 @@ export async function startFeature(
356
363
  // brief rides `appendPrompt` in the implement task, exactly like the epic implementer.
357
364
  baseBranch: base,
358
365
  baseBranchBrief: renderBaseBranchBrief(base),
366
+ // Optional operator steering, appended to the implement agent's prompt via the implement
367
+ // task's `appendPrompt` FEEL (feature.bpmn). Null when none was supplied; persists on the
368
+ // instance so it also rides the answer-loop redispatch back into the same implement task.
369
+ customInstructions: instructions,
359
370
  },
360
371
  });
361
372
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles } from "./github.ts";
6
+ import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -236,3 +236,26 @@ test("ensureBaseBranch: missing non-epic/* branch throws BaseBranchMustExistErro
236
236
  // A rejected non-epic/* base must never spawn a wrong-rooted branch.
237
237
  assertEquals(state.creates.length, 0);
238
238
  });
239
+
240
+ // A `Depends-on:` ref that resolves to an issue (or a non-existent number) can never merge, so the
241
+ // merge-poller's dependency gate must treat it as non-blocking rather than wedging forever — the
242
+ // exact wedge behind Magikcraft/nano-bpm#806 declaring `Depends-on:` its epic tracking *issue*
243
+ // #796. `isNotAPullRequestError` is the discriminator: it must fire for both transports' "not a PR"
244
+ // signals and stay false for transient failures (which must keep the dependency blocking).
245
+ test("isNotAPullRequestError: gh GraphQL 'not a PullRequest' → true", () => {
246
+ const err = new Error(
247
+ "GraphQL: Could not resolve to a PullRequest with the number of 796. (repository.pullRequest)",
248
+ );
249
+ assertEquals(isNotAPullRequestError(err), true);
250
+ });
251
+
252
+ test("isNotAPullRequestError: token-mode 404 → true", () => {
253
+ assertEquals(isNotAPullRequestError(new Error("github 404 Not Found")), true);
254
+ });
255
+
256
+ test("isNotAPullRequestError: transient failures stay blocking (false)", () => {
257
+ assertEquals(isNotAPullRequestError(new Error("github 502 Bad Gateway")), false);
258
+ assertEquals(isNotAPullRequestError(new Error("API rate limit exceeded")), false);
259
+ assertEquals(isNotAPullRequestError(new Error("fetch failed")), false);
260
+ assertEquals(isNotAPullRequestError(null), false);
261
+ });
package/app/github.ts CHANGED
@@ -496,6 +496,20 @@ function allCheckNames(rollup: RollupEntry[]): string[] {
496
496
  return names;
497
497
  }
498
498
 
499
+ /** True when `err` is GitHub reporting that a ref which parsed as `owner/repo#N` is not a pull
500
+ * request — either it's an issue (issues and PRs share GitHub's number space, so an issue number
501
+ * is indistinguishable from a PR number by shape alone) or the number does not exist. Both
502
+ * transports surface here: `gh` mode throws the GraphQL message "Could not resolve to a
503
+ * PullRequest with the number of N", and token mode throws `github 404 …` from
504
+ * `GET /repos/{repo}/pulls/{N}`. A ref that is not a pull request can never merge, so a caller
505
+ * gating a merge queue on it (see `isDepMerged`) must treat it as non-blocking instead of wedging
506
+ * forever. Transient failures (rate-limit, 5xx, network) deliberately return `false` so the caller
507
+ * keeps waiting/retrying rather than silently clearing a real dependency. */
508
+ export function isNotAPullRequestError(err: unknown): boolean {
509
+ const msg = err instanceof Error ? err.message : String(err);
510
+ return /could not resolve to a pullrequest/i.test(msg) || /\bgithub 404\b/i.test(msg);
511
+ }
512
+
499
513
  export async function fetchPrState(
500
514
  repo: string,
501
515
  number: number | string,
package/app/service.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  fetchPrReviews,
21
21
  fetchPrState,
22
22
  hasPendingCopilotReviewer,
23
+ isNotAPullRequestError,
23
24
  type MergeMethod,
24
25
  type PrState,
25
26
  requestCopilotReview,
@@ -738,8 +739,23 @@ async function isDepMerged(data: DataLayer, depKey: string, token: string): Prom
738
739
  if (tracked && tracked.status === "merged") return true;
739
740
  const parsed = parsePr(depKey);
740
741
  if (!parsed) return true; // unparseable dep can't be checked on GitHub → treat as cleared so it never wedges the PR
741
- const st = await fetchPrState(parsed.repo, parsed.number, token);
742
- return st?.merged ?? false;
742
+ try {
743
+ const st = await fetchPrState(parsed.repo, parsed.number, token);
744
+ return st?.merged ?? false;
745
+ } catch (err) {
746
+ // A ref that GitHub cannot resolve to a *pull request* — it's an issue (issues and PRs share
747
+ // GitHub's number space) or the number doesn't exist — can never merge, so it cannot gate a
748
+ // merge queue. Treat it as cleared (non-blocking) rather than wedging the run at `wait-deps`
749
+ // forever, as happened when a PR body declared `Depends-on:` its epic tracking *issue*
750
+ // (Magikcraft/nano-bpm#806 → #796). Transient failures rethrow so the poller logs and retries.
751
+ if (isNotAPullRequestError(err)) {
752
+ console.warn(
753
+ `[poller] dep ${depKey} is not a mergeable pull request (issue or missing) — treating as non-blocking`,
754
+ );
755
+ return true;
756
+ }
757
+ throw err;
758
+ }
743
759
  }
744
760
 
745
761
  /** Flip a PR into the transient `merging` status and publish the correlating message, reverting
package/openapi.yaml CHANGED
@@ -668,6 +668,13 @@ components:
668
668
  description: >-
669
669
  Opt in to sharing a custom integration base branch with another already-active epic. See
670
670
  `PlanStartByIssue.allowSharedBase`.
671
+ customInstructions:
672
+ type: string
673
+ maxLength: 8000
674
+ description: >-
675
+ OPTIONAL free-text steering appended to the implementation agent's prompt for this run
676
+ (via the implement task's `appendPrompt`). Blank/whitespace is treated as absent. Persists
677
+ on the instance, so it also applies to the agent's answer-loop redispatch.
671
678
  FeatureStartByUrl:
672
679
  type: object
673
680
  additionalProperties: false
@@ -698,6 +705,12 @@ components:
698
705
  allowSharedBase:
699
706
  type: boolean
700
707
  description: Share a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
708
+ customInstructions:
709
+ type: string
710
+ maxLength: 8000
711
+ description: >-
712
+ OPTIONAL free-text steering appended to the implementation agent's prompt for this run.
713
+ See `FeatureStartByIssue.customInstructions`.
701
714
  MessageResult:
702
715
  type: object
703
716
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -115,12 +115,18 @@ export default defineOperation("startFeature", async ({ body }, app) => {
115
115
  // Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
116
116
  // the persisted row and the process variable can't disagree.
117
117
  const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
118
- const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge);
118
+ // Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
119
+ // normalized to null downstream (startFeature) so it never appends an empty instruction block.
120
+ const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
121
+ ? body.customInstructions
122
+ : null;
123
+ const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge, customInstructions);
119
124
  app.log.info("feature run started", {
120
125
  featureKey: parsed.planKey,
121
126
  requestedBaseBranch: normalizedBase,
122
127
  converge,
123
128
  autoMerge,
129
+ hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
124
130
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
125
131
  });
126
132
  return { status: 202, body: result };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -42,6 +42,7 @@
42
42
  "fields": [
43
43
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
44
44
  { "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
45
+ { "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
45
46
  { "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
46
47
  { "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
47
48
  { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
@@ -44,7 +44,7 @@
44
44
  <zeebe:linkedResource resourceId="feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
45
45
  </zeebe:linkedResources>
46
46
  <zeebe:ioMapping>
47
- <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief)" target="appendPrompt" />
47
+ <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief) + (if (customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
48
48
  <zeebe:output source="=status" target="status" />
49
49
  <zeebe:output source="=question" target="question" />
50
50
  </zeebe:ioMapping>