@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 +14 -0
- package/app/feature.test.ts +36 -0
- package/app/feature.ts +11 -0
- package/app/github.test.ts +24 -1
- package/app/github.ts +14 -0
- package/app/service.ts +18 -2
- package/openapi.yaml +13 -0
- package/operations/startFeature.ts +7 -1
- package/package.json +1 -1
- package/pages/feature.page.json +1 -0
- package/resources/processes/feature.bpmn +1 -1
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
|
|
package/app/feature.test.ts
CHANGED
|
@@ -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);
|
package/app/github.test.ts
CHANGED
|
@@ -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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/pages/feature.page.json
CHANGED
|
@@ -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="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief)" target="appendPrompt" />
|
|
47
|
+
<zeebe:input source="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief) + (if (customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
48
48
|
<zeebe:output source="=status" target="status" />
|
|
49
49
|
<zeebe:output source="=question" target="question" />
|
|
50
50
|
</zeebe:ioMapping>
|