@nanobpm/nano-workforce 0.46.1 → 0.46.2

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,10 @@
1
+ ## [0.46.2](https://github.com/nanobpm/nano-workforce/compare/v0.46.1...v0.46.2) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **prompts:** make merge-phase agents emit a machine-readable result ([#133](https://github.com/nanobpm/nano-workforce/issues/133)) ([6584092](https://github.com/nanobpm/nano-workforce/commit/6584092b3ef8f15159c024c080b312ead7484076)), closes [Magikcraft/nano-bpm#746](https://github.com/Magikcraft/nano-bpm/issues/746)
7
+
1
8
  ## [0.46.1](https://github.com/nanobpm/nano-workforce/compare/v0.46.0...v0.46.1) (2026-08-12)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.46.1",
3
+ "version": "0.46.2",
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/prompts/fix-ci.md CHANGED
@@ -84,3 +84,48 @@ Return a structured result:
84
84
  Never report `fixed` unless you actually pushed a change. If nothing was wrong on
85
85
  the branch (the failure was transient infrastructure), say so in `summary` and
86
86
  return `blocked` so a human can decide whether to just retry the merge.
87
+
88
+ ### How to return it (the wire mechanism)
89
+
90
+ Your result variables only reach the process if you emit them through the harness's
91
+ result channel. Prose in your normal output is **not** parsed — if you only "say"
92
+ your status in the transcript, the process can't read it, falls back to its safe
93
+ default (a merge escalation a human must clear), and the merge stalls. So emit a
94
+ machine-readable result one of two ways:
95
+
96
+ 1. **Write a JSON object to the file at `$AGENT_RESULT_FILE`** (an env var the
97
+ harness sets for you). The object's keys become process variables. Examples:
98
+
99
+ ```sh
100
+ # pushed a fix you believe turns the failing checks green:
101
+ printf '%s' '{"status":"fixed","summary":"Fixed the flaky timeout in auth.test.ts and pushed"}' > "$AGENT_RESULT_FILE"
102
+ # ordering constraint — must wait for another PR to land first:
103
+ printf '%s' '{"status":"waiting-on-pr","summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
104
+ # genuinely stuck — a human must decide:
105
+ printf '%s' '{"status":"blocked","summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}' > "$AGENT_RESULT_FILE"
106
+ ```
107
+
108
+ Write this file **once**, at the very end, with your final result. Keep it a flat
109
+ JSON object of exactly the variables named in the return contract above.
110
+
111
+ 2. **Fallback** (only if you truly cannot write the file): print a single line to
112
+ stdout of the form `::nano:result:: {json}` — e.g.
113
+
114
+ ```
115
+ ::nano:result:: {"status":"fixed","summary":"Corrected the type error in handler.ts and pushed"}
116
+ ```
117
+
118
+ The harness reads the **last** such line. A trailing fenced JSON code block is also
119
+ accepted as a last resort.
120
+
121
+ Do not put the result file inside the repo checkout or `git add` it — it lives
122
+ outside your workspace. Exit `0` for every status (including `blocked`/`waiting-on-pr`);
123
+ a non-zero exit means a genuine crash and the job is retried.
124
+
125
+ **Emitting a machine-readable result is your mandatory final step — never exit
126
+ silently.** It is the last thing you do on every path out of this job (including after
127
+ a push, or when you conclude nothing can be fixed). If you are ever unsure which status
128
+ applies, return **`blocked`** with a `summary` and a concrete `question` rather than
129
+ leaving without a result — a missing result is treated as an unclassified merge
130
+ escalation that pulls in a human and stalls the merge, so relying on that default
131
+ wastes the attempt.
package/prompts/rebase.md CHANGED
@@ -100,3 +100,48 @@ was needed, so the process simply re-attempts the merge; the rebase budget
100
100
  bounds how many times a still-stuck PR can loop here before it escalates. Reserve
101
101
  `blocked` for a genuine semantic conflict you cannot resolve mechanically (or a
102
102
  branch that is un-rebaseable), so a human can decide.
103
+
104
+ ### How to return it (the wire mechanism)
105
+
106
+ Your result variables only reach the process if you emit them through the harness's
107
+ result channel. Prose in your normal output is **not** parsed — if you only "say"
108
+ your status in the transcript, the process can't read it, falls back to its safe
109
+ default (a merge escalation a human must clear), and the merge stalls. So emit a
110
+ machine-readable result one of two ways:
111
+
112
+ 1. **Write a JSON object to the file at `$AGENT_RESULT_FILE`** (an env var the
113
+ harness sets for you). The object's keys become process variables. Examples:
114
+
115
+ ```sh
116
+ # branch tip now contains the latest base (you pushed a resolved rebase, or it was already up to date):
117
+ printf '%s' '{"status":"rebased","summary":"Rebased onto main, resolved 2 conflicts in router.ts, pushed"}' > "$AGENT_RESULT_FILE"
118
+ # ordering constraint — must wait for another PR to land first:
119
+ printf '%s' '{"status":"waiting-on-pr","summary":"Stacked on the base PR that has not merged","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
120
+ # genuine semantic conflict — a human must decide which behaviour wins:
121
+ printf '%s' '{"status":"blocked","summary":"main and this branch both rewrote retry() incompatibly","question":"Should retries stay capped at 3 (main) or become unbounded (this PR)?"}' > "$AGENT_RESULT_FILE"
122
+ ```
123
+
124
+ Write this file **once**, at the very end, with your final result. Keep it a flat
125
+ JSON object of exactly the variables named in the return contract above.
126
+
127
+ 2. **Fallback** (only if you truly cannot write the file): print a single line to
128
+ stdout of the form `::nano:result:: {json}` — e.g.
129
+
130
+ ```
131
+ ::nano:result:: {"status":"rebased","summary":"Already up to date; no push needed"}
132
+ ```
133
+
134
+ The harness reads the **last** such line. A trailing fenced JSON code block is also
135
+ accepted as a last resort.
136
+
137
+ Do not put the result file inside the repo checkout or `git add` it — it lives
138
+ outside your workspace. Exit `0` for every status (including `blocked`/`waiting-on-pr`);
139
+ a non-zero exit means a genuine crash and the job is retried.
140
+
141
+ **Emitting a machine-readable result is your mandatory final step — never exit
142
+ silently.** It is the last thing you do on every path out of this job (including after
143
+ a force-push, or when the branch was already up to date). If you are ever unsure which
144
+ status applies and the branch tip contains the latest base, return **`rebased`** with a
145
+ `summary`; otherwise return **`blocked`** with a concrete `question` — never leave
146
+ without a result. A missing result is treated as an unclassified merge escalation that
147
+ pulls in a human and stalls the merge, so relying on that default wastes the attempt.
@@ -181,7 +181,7 @@ default, and you waste a round. So emit a machine-readable result one of two way
181
181
  ::nano:result:: {"status":"converged","summary":"No actionable comments left"}
182
182
  ```
183
183
 
184
- The harness reads the **last** such line. A trailing ```json fenced block is also
184
+ The harness reads the **last** such line. A trailing fenced JSON code block is also
185
185
  accepted as a last resort.
186
186
 
187
187
  Do not put the result file inside the repo checkout or `git add` it — it lives
@@ -30,11 +30,11 @@ function fixture(files: Record<string, string>): string {
30
30
  return root;
31
31
  }
32
32
 
33
- test("passes when every {{token}} resolves to a non-blank template", async () => {
33
+ test("passes when every {{token}} resolves to a non-blank template that emits a result", async () => {
34
34
  const root = await fixture({
35
35
  "nano.app.json": MANIFEST,
36
36
  "resources/processes/loop.bpmn": header("{{review-round}}"),
37
- "prompts/review-round.md": "# Round\nDo the thing.",
37
+ "prompts/review-round.md": "# Round\nDo the thing, then write your result to `$AGENT_RESULT_FILE`.",
38
38
  });
39
39
  const res = checkAgentPrompts(root);
40
40
  assertEquals(res.errors, []);
@@ -75,6 +75,31 @@ test("fails when a reserved agent-prompt header is blank", async () => {
75
75
  assert(res.errors.some((e) => e.includes("is empty")));
76
76
  });
77
77
 
78
+ test("fails when an agent-prompt template omits the machine-readable result mechanism", async () => {
79
+ // A prompt wired as an agent's base prompt must tell it to write $AGENT_RESULT_FILE (or use the
80
+ // ::nano:result:: fallback). Without it the agent finishes with prose only, `status` comes back
81
+ // blank, and the status gateway escalates/stalls — the fix-ci/rebase gap behind #746's stuck merge.
82
+ const root = await fixture({
83
+ "nano.app.json": MANIFEST,
84
+ "resources/processes/loop.bpmn": header("{{review-round}}"),
85
+ "prompts/review-round.md": "# Round\nReturn status: converged. (but never says how to emit it)",
86
+ });
87
+ const res = checkAgentPrompts(root);
88
+ assert(!res.ok);
89
+ assert(res.errors.some((e) => e.includes("{{review-round}}") && e.includes("AGENT_RESULT_FILE")));
90
+ });
91
+
92
+ test("passes when an agent-prompt template emits via the ::nano:result:: fallback", async () => {
93
+ const root = await fixture({
94
+ "nano.app.json": MANIFEST,
95
+ "resources/processes/loop.bpmn": header("{{review-round}}"),
96
+ "prompts/review-round.md": "# Round\nEmit `::nano:result:: {\"status\":\"converged\"}` at the end.",
97
+ });
98
+ const res = checkAgentPrompts(root);
99
+ assertEquals(res.errors, []);
100
+ assert(res.ok);
101
+ });
102
+
78
103
  test("checks the real repo: all committed agent prompts resolve", () => {
79
104
  // The guard must be green against the actual app it protects — this is the case CI relies on.
80
105
  const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
@@ -82,7 +107,7 @@ test("checks the real repo: all committed agent prompts resolve", () => {
82
107
  assertEquals(res.errors, []);
83
108
  assert(res.ok);
84
109
  // Every senior:* agent prompt header in the three processes must have resolved.
85
- for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge"]) {
110
+ for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge", "rebase", "retro"]) {
86
111
  assert(res.resolved.includes(t), `expected template ${t} to resolve`);
87
112
  }
88
113
  });
@@ -72,6 +72,33 @@ function hasBlankAgentPromptHeader(bpmn: string): boolean {
72
72
  return false;
73
73
  }
74
74
 
75
+ // The template tokens a model wires as an agent's base prompt, e.g. the `fix-ci` in
76
+ // `value="{{fix-ci}}"` on an `io.nanobpm.agentTask.task.prompt` header. These templates *drive an
77
+ // agent*, so each must teach it to emit a machine-readable result (see agentPromptEmitsResult).
78
+ function agentPromptTokens(bpmn: string): string[] {
79
+ const tokens: string[] = [];
80
+ const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
81
+ let m = re.exec(bpmn);
82
+ while (m !== null) {
83
+ if (m[1] === AGENT_PROMPT_HEADER) {
84
+ const tok = /^\{\{\s*([^}]+?)\s*\}\}$/.exec(m[2].trim());
85
+ if (tok) tokens.push(tok[1]);
86
+ }
87
+ m = re.exec(bpmn);
88
+ }
89
+ return tokens;
90
+ }
91
+
92
+ // A prompt that drives an agent must tell it how to return a machine-readable result — the
93
+ // `$AGENT_RESULT_FILE` write (or the `::nano:result::` stdout fallback). Without it the agent can
94
+ // finish with prose only, its `status` variable comes back empty, the status gateway falls through
95
+ // to its default escalation arm, and the run parks a human escalation / stalls the merge (the
96
+ // fix-ci/rebase gap behind Magikcraft/nano-bpm#746's stuck merge). Prose is never parsed, so this
97
+ // instruction is load-bearing, not documentation.
98
+ function agentPromptEmitsResult(body: string): boolean {
99
+ return body.includes("AGENT_RESULT_FILE") || body.includes("::nano:result::");
100
+ }
101
+
75
102
  export interface CheckResult {
76
103
  ok: boolean;
77
104
  errors: string[];
@@ -82,6 +109,7 @@ export interface CheckResult {
82
109
  export function checkAgentPrompts(root: string): CheckResult {
83
110
  const errors: string[] = [];
84
111
  const resolved = new Set<string>();
112
+ const agentTokens = new Set<string>();
85
113
 
86
114
  const manifestPath = join(root, "nano.app.json");
87
115
  if (!existsSync(manifestPath)) {
@@ -127,6 +155,20 @@ export function checkAgentPrompts(root: string): CheckResult {
127
155
  if (hasBlankAgentPromptHeader(content)) {
128
156
  errors.push(`${rel}: a reserved "${AGENT_PROMPT_HEADER}" header is empty (agent would run prompt-less)`);
129
157
  }
158
+ for (const tok of agentPromptTokens(content)) agentTokens.add(tok);
159
+ }
160
+
161
+ // Every template wired as an agent's base prompt must teach the agent to emit a machine-readable
162
+ // result; a prose-only agent leaves `status` blank and the process escalates/stalls.
163
+ for (const tok of [...agentTokens].sort()) {
164
+ const body = templates[tok];
165
+ if (body != null && body.trim() !== "" && !agentPromptEmitsResult(body)) {
166
+ errors.push(
167
+ `template {{${tok}}} drives an agent but never tells it to write $AGENT_RESULT_FILE ` +
168
+ `(or the ::nano:result:: fallback) — the agent can finish with prose only, leaving its ` +
169
+ `status blank so the process escalates/stalls`,
170
+ );
171
+ }
130
172
  }
131
173
 
132
174
  return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };