@nanobpm/nano-workforce 0.161.0 → 0.162.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.
- package/AGENTS.md +12 -6
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/SPEC.md +8 -6
- package/app/convergeGate.test.ts +1 -1
- package/app/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/app/service.ts +1 -1
- package/docs/agent-guide.md +12 -1
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +142 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +2 -2
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/implement-cell.bpmn +1 -1
- package/resources/processes/merge-cell.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +4 -4
- package/resources/processes/retro.bpmn +2 -2
- package/scripts/check-agent-prompts.test.ts +21 -2
- package/scripts/check-agent-prompts.ts +46 -27
- package/test/derivation-parity/flows.ts +2 -2
package/AGENTS.md
CHANGED
|
@@ -120,7 +120,8 @@ GENERATED, never hand-edited.**
|
|
|
120
120
|
## Deploy by convention: `resources/` (ADR 0062)
|
|
121
121
|
|
|
122
122
|
**Deployables live under `resources/` and deploy by convention — `nano.app.json`
|
|
123
|
-
declares no `models`.** urban walks `resources/` (
|
|
123
|
+
declares no `models`.** urban walks `resources/` **recursively (every file at any depth)** and
|
|
124
|
+
deploys
|
|
124
125
|
every file: `resources/processes/*.bpmn`, `resources/forms/*.form`, and one prompt per
|
|
125
126
|
task under `resources/prompts/*.md`.
|
|
126
127
|
|
|
@@ -131,15 +132,20 @@ task under `resources/prompts/*.md`.
|
|
|
131
132
|
- **No `models` block.** Rely on the convention; add a `models` override *only* for a
|
|
132
133
|
genuinely non-standard layout (nwf doesn't need one). An explicit `models` is used
|
|
133
134
|
verbatim and skips the convention walk.
|
|
134
|
-
- **
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
- **Prompt ids are `resources/`-relative; basenames must be unique only within a
|
|
136
|
+
`models` override.** A convention resource's deploy id (`resourceId`) is its path
|
|
137
|
+
**relative to `resources/`** — `resources/prompts/plan.md` → `prompts/plan.md` — so files
|
|
138
|
+
sharing a basename in different sub-dirs deploy as distinct resources. Only a `models`
|
|
139
|
+
override keys by basename (and so must avoid basename collisions). `npm run check:prompts`
|
|
140
|
+
validates each prompt link against the actual deployed id and fails loudly on a mismatch.
|
|
137
141
|
- **Agent prompts: linkedResource is the blessed *and only* path.** Each agent service
|
|
138
142
|
task links its base prompt with
|
|
139
|
-
`<zeebe:linkedResource resourceId="
|
|
143
|
+
`<zeebe:linkedResource resourceId="prompts/<token>.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>`,
|
|
140
144
|
which the engine resolves to the latest deployed `resources/prompts/<token>.md` at job
|
|
141
145
|
activation, combined at runtime with the per-task `appendPrompt` FEEL. `bindingType="latest"`
|
|
142
|
-
lets a prompt update land mid-epic without a process redeploy.
|
|
146
|
+
lets a prompt update land mid-epic without a process redeploy. **The id is the
|
|
147
|
+
`resources/`-relative path (`prompts/<token>.md`), NOT the bare basename** — a bare id
|
|
148
|
+
resolves to nothing and the engine silently omits the link, so the agent runs prompt-less.
|
|
143
149
|
- **Deploy-time `{{token}}` templating is removed — no back-compat.** To inject a per-run
|
|
144
150
|
value (URL, flag) into an agent, pass it as a runtime job variable / `appendPrompt`
|
|
145
151
|
FEEL; never bake it into a model at deploy time.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.162.1](https://github.com/nanobpm/nano-workforce/compare/v0.162.0...v0.162.1) (2026-08-30)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **prompts:** reference prompts by resources-relative id so agents get their base prompt ([#628](https://github.com/nanobpm/nano-workforce/issues/628)) ([084e8f8](https://github.com/nanobpm/nano-workforce/commit/084e8f8f4ad842cc9f9e92c25b4ef80d0b0b66e1)), closes [#241](https://github.com/nanobpm/nano-workforce/issues/241)
|
|
6
|
+
|
|
7
|
+
## [0.162.0](https://github.com/nanobpm/nano-workforce/compare/v0.161.0...v0.162.0) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add sequenceIssues intent door for canonical delivery-graph chains ([#618](https://github.com/nanobpm/nano-workforce/issues/618)) ([698f4bf](https://github.com/nanobpm/nano-workforce/commit/698f4bf5b7e873bd1805ea4c671162db7037de5a)), closes [#605](https://github.com/nanobpm/nano-workforce/issues/605) [#610](https://github.com/nanobpm/nano-workforce/issues/610)
|
|
12
|
+
|
|
1
13
|
## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/README.md
CHANGED
|
@@ -499,7 +499,7 @@ agent's base prompt is **not** in the job payload — it is delivered as a
|
|
|
499
499
|
**linked resource** (likewise `plan.md`, `feature.md`, `fix-ci.md`, …):
|
|
500
500
|
|
|
501
501
|
```xml
|
|
502
|
-
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
|
|
502
|
+
<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
|
|
503
503
|
```
|
|
504
504
|
|
|
505
505
|
that the engine resolves to the latest deployed `resources/prompts/*.md` at job
|
package/SPEC.md
CHANGED
|
@@ -164,7 +164,7 @@ Notes:
|
|
|
164
164
|
|
|
165
165
|
The base instructions are **not** a job variable: they are delivered as a
|
|
166
166
|
**linked resource** on the `senior:pr-review` task —
|
|
167
|
-
`<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>`,
|
|
167
|
+
`<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>`,
|
|
168
168
|
which the engine resolves to the latest deployed `resources/prompts/review-round.md` at job
|
|
169
169
|
activation.
|
|
170
170
|
|
|
@@ -319,14 +319,16 @@ when the engine data is purged, to keep app state and engine state consistent).
|
|
|
319
319
|
Each agent task's base prompt lives **only** in its `resources/prompts/*.md` side-car,
|
|
320
320
|
deployed as a **generic resource** and **linked** — not baked — into the model (issue
|
|
321
321
|
#169). Under the ADR 0062 `resources/` deploy-by-convention layout, `nano.app.json`
|
|
322
|
-
declares **no `models`**, so `@nanobpm/urban` walks `resources/` (
|
|
323
|
-
deploys each file as an
|
|
324
|
-
|
|
325
|
-
Each agent service
|
|
322
|
+
declares **no `models`**, so `@nanobpm/urban` walks `resources/` **recursively (every file at any
|
|
323
|
+
depth)** and deploys each file as an
|
|
324
|
+
`application/octet-stream` resource whose deployed **id is its path relative to `resources/`**
|
|
325
|
+
(`resources/prompts/review-round.md` → resource `prompts/review-round.md`). Each agent service
|
|
326
|
+
task links it by that `resources/`-relative id (a bare basename resolves to nothing and the
|
|
327
|
+
engine silently omits the link, leaving the agent prompt-less):
|
|
326
328
|
|
|
327
329
|
```xml
|
|
328
330
|
<zeebe:linkedResources>
|
|
329
|
-
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
331
|
+
<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
330
332
|
</zeebe:linkedResources>
|
|
331
333
|
```
|
|
332
334
|
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -394,7 +394,7 @@ test("classify-scope is an agent task servicing senior:scope-classify with a lin
|
|
|
394
394
|
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="classify-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
395
395
|
assert(task, "classify-scope service task missing");
|
|
396
396
|
assertStringIncludes(task[0], 'type="senior:scope-classify"');
|
|
397
|
-
assertStringIncludes(task[0], 'resourceId="scope-classify.md"');
|
|
397
|
+
assertStringIncludes(task[0], 'resourceId="prompts/scope-classify.md"');
|
|
398
398
|
assertStringIncludes(task[0], 'linkName="prompt"');
|
|
399
399
|
// After honouring (or ignoring) the human's scope answer, it clears the one-shot
|
|
400
400
|
// scopeAnswer so a later round does not re-honour a stale decision.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// nano-workforce — the ONE compile-and-stage flow shared by every agent-facing door that stages a
|
|
2
|
+
// delivery graph (epic nano-workforce#605). Extracted from `operations/compileDeliveryGraph.ts` (S0)
|
|
3
|
+
// so the intent-shaped generator doors (S4 — `sequenceIssues`) hand their CONSTRUCTED graph to the
|
|
4
|
+
// EXACT same deterministic compile → validate → stage path the raw `compileDeliveryGraph` door uses:
|
|
5
|
+
// one compiler (`compileDeliveryGraph`), one staging path (`stageProposal`), one idempotency/digest
|
|
6
|
+
// semantics (content-addressed by `deliveryGraphDigest`). Per AGENTS.md "Derivation over duplication:
|
|
7
|
+
// no drift surfaces", a generator MUST NOT re-implement a second runner or a second staging path —
|
|
8
|
+
// it only produces the `DeliveryGraph` and delegates here.
|
|
9
|
+
//
|
|
10
|
+
// The result mirrors the `compileDeliveryGraph` operation's wire contract exactly: a `200` carrying
|
|
11
|
+
// the `CompileDeliveryGraphStaged` preview + navigational `reviewUrl` (NO dispatch handle — dispatch
|
|
12
|
+
// stays an operator-only cockpit action, ADR 0005 Decision 7 / issue #460), or a `400` carrying the
|
|
13
|
+
// `CompileDeliveryGraphErrors` `{ ok:false, errors:[{path,message}] }` when the graph fails shape or
|
|
14
|
+
// semantic validation. Nothing is staged on a rejected compile.
|
|
15
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
16
|
+
import type { CompileDeliveryGraphErrors, CompileDeliveryGraphStaged } from "../nano-generated/api-io.d.ts";
|
|
17
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
18
|
+
import {
|
|
19
|
+
buildProposalPreview,
|
|
20
|
+
buildProposalRow,
|
|
21
|
+
proposalLogicalKey,
|
|
22
|
+
proposalReviewUrl,
|
|
23
|
+
stageProposal,
|
|
24
|
+
} from "./deliveryGraphProposals.ts";
|
|
25
|
+
import { deliveryGraphDigest } from "./deliveryRunner.ts";
|
|
26
|
+
|
|
27
|
+
/** The human-readable instruction every staged compile hands back — the agent's surface ends here;
|
|
28
|
+
* dispatch is an operator action and there is no start endpoint (capability-by-absence, #460). */
|
|
29
|
+
export const STAGED_MESSAGE =
|
|
30
|
+
"The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
|
|
31
|
+
|
|
32
|
+
/** A successful stage — the `CompileDeliveryGraphStaged` body plus the compiled `digest` and node
|
|
33
|
+
* counts, so a caller can log what it staged without re-deriving them. */
|
|
34
|
+
export interface StagedResult {
|
|
35
|
+
ok: true;
|
|
36
|
+
status: 200;
|
|
37
|
+
body: CompileDeliveryGraphStaged;
|
|
38
|
+
digest: string;
|
|
39
|
+
nodeCount: number;
|
|
40
|
+
humanNodeCount: number;
|
|
41
|
+
sideEffectCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
|
|
45
|
+
export interface StageErrors {
|
|
46
|
+
ok: false;
|
|
47
|
+
status: 400;
|
|
48
|
+
body: CompileDeliveryGraphErrors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compile a `DeliveryGraph` and, when valid, persist it as a `staged` proposal — the single
|
|
53
|
+
* compile+stage flow (see the module header). `graph` is the structured `DeliveryGraph` object (an
|
|
54
|
+
* agent-authored one from `compileDeliveryGraph`, or a generator-CONSTRUCTED one from a `start/*`
|
|
55
|
+
* intent door); `graphJson` is the serialised form persisted on the proposal row for the cockpit
|
|
56
|
+
* dispatch to re-run (the caller supplies it so a generator can persist the SAME object it compiled,
|
|
57
|
+
* byte-for-byte). `origin` is the public origin the request arrived on, used to build the
|
|
58
|
+
* navigational `reviewUrl`. Never dispatches; never throws for a malformed graph (the compiler maps
|
|
59
|
+
* unknown input to a clean `ok:false`).
|
|
60
|
+
*/
|
|
61
|
+
export async function compileAndStageDeliveryGraph(
|
|
62
|
+
data: DataLayer,
|
|
63
|
+
graph: unknown,
|
|
64
|
+
graphJson: string,
|
|
65
|
+
origin: string,
|
|
66
|
+
): Promise<StagedResult | StageErrors> {
|
|
67
|
+
const result = await compileDeliveryGraph(graph);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
return { ok: false, status: 400, body: result };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const digest = deliveryGraphDigest(result.bpmn);
|
|
73
|
+
const name =
|
|
74
|
+
typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
|
|
75
|
+
? result.resolved.name.trim()
|
|
76
|
+
: null;
|
|
77
|
+
const preview = buildProposalPreview(result);
|
|
78
|
+
await stageProposal(
|
|
79
|
+
data,
|
|
80
|
+
buildProposalRow({
|
|
81
|
+
digest,
|
|
82
|
+
logicalKey: proposalLogicalKey(name, digest),
|
|
83
|
+
title: name,
|
|
84
|
+
graphJson,
|
|
85
|
+
preview,
|
|
86
|
+
nodeCount: result.resolved.nodes.length,
|
|
87
|
+
humanNodeCount: result.humanNodes.length,
|
|
88
|
+
sideEffectCount: result.sideEffects.length,
|
|
89
|
+
sideEffecting: result.sideEffects.length > 0,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
status: 200,
|
|
96
|
+
body: {
|
|
97
|
+
status: "ready",
|
|
98
|
+
message: STAGED_MESSAGE,
|
|
99
|
+
digest,
|
|
100
|
+
preview,
|
|
101
|
+
reviewUrl: proposalReviewUrl(digest, origin),
|
|
102
|
+
},
|
|
103
|
+
digest,
|
|
104
|
+
nodeCount: result.resolved.nodes.length,
|
|
105
|
+
humanNodeCount: result.humanNodes.length,
|
|
106
|
+
sideEffectCount: result.sideEffects.length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Tests for the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). The core acceptance guard: the generated graph is EQUIVALENT to the
|
|
3
|
+
// hand-authored canonical §9.4 chain for the same inputs — same node kinds, edges, and `pr`-fact
|
|
4
|
+
// threading — so an agent never re-authors 13 nodes + 12 edges by hand. Also pins the input/vocabulary
|
|
5
|
+
// validation contract (`issues[{path,message}]`).
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
9
|
+
import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
10
|
+
import { buildSequenceGraph, MAX_SEQUENCE_ISSUES, MERGE_POLL } from "./sequenceIssues.ts";
|
|
11
|
+
|
|
12
|
+
type AnyGraph = { name?: string; nodes: any[]; edges: any[] };
|
|
13
|
+
|
|
14
|
+
function ok(intent: unknown): AnyGraph {
|
|
15
|
+
const res = buildSequenceGraph(intent);
|
|
16
|
+
assert(res.ok, `expected ok, got ${JSON.stringify(res)}`);
|
|
17
|
+
return res.graph as AnyGraph;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The hand-authored canonical chain for the SAME inputs — what §9.4 says an agent would build by
|
|
21
|
+
* hand. The generator must produce a structurally-equivalent graph. */
|
|
22
|
+
function handAuthored(behind: string | null, issues: string[]): AnyGraph {
|
|
23
|
+
const nodes: any[] = [];
|
|
24
|
+
const edges: any[] = [];
|
|
25
|
+
if (behind) {
|
|
26
|
+
nodes.push({
|
|
27
|
+
id: "gate-epic",
|
|
28
|
+
kind: "wait",
|
|
29
|
+
wait: { kind: "epic", target: behind, match: { epicState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
|
|
30
|
+
emits: [{ name: "prCount", type: "number" }],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
issues.forEach((issue, i) => {
|
|
34
|
+
const n = i + 1;
|
|
35
|
+
nodes.push({
|
|
36
|
+
id: `open-${n}`,
|
|
37
|
+
kind: "agent",
|
|
38
|
+
agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.` },
|
|
39
|
+
emits: [{ name: "pr", type: "pr" }],
|
|
40
|
+
});
|
|
41
|
+
nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
|
|
42
|
+
nodes.push({
|
|
43
|
+
id: `merged-${n}`,
|
|
44
|
+
kind: "wait",
|
|
45
|
+
wait: { kind: "pr", target: `open-${n}.pr`, match: { prState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
|
|
46
|
+
});
|
|
47
|
+
edges.push({ from: `open-${n}.pr`, to: `land-${n}` });
|
|
48
|
+
edges.push({ from: `open-${n}.pr`, to: `merged-${n}` });
|
|
49
|
+
if (i === 0) {
|
|
50
|
+
if (behind) edges.push({ from: "gate-epic", to: `open-${n}` });
|
|
51
|
+
} else {
|
|
52
|
+
edges.push({ from: `merged-${i}`, to: `open-${n}` });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return { nodes, edges };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compare two graphs by their SEMANTIC content — node set (by id) and edge set — order-insensitive. */
|
|
59
|
+
function assertEquivalent(actual: AnyGraph, expected: AnyGraph): void {
|
|
60
|
+
const byId = (g: AnyGraph) => new Map(g.nodes.map((n) => [n.id, n]));
|
|
61
|
+
const a = byId(actual);
|
|
62
|
+
const e = byId(expected);
|
|
63
|
+
assertEquals([...a.keys()].sort(), [...e.keys()].sort(), "same node ids");
|
|
64
|
+
for (const [id, node] of e) assertEquals(a.get(id), node, `node ${id} matches canonical`);
|
|
65
|
+
const edgeKey = (x: any) => JSON.stringify([x.from, x.to, x.when ?? null, x.equals ?? null, x.default ?? null]);
|
|
66
|
+
assertEquals(actual.edges.map(edgeKey).sort(), expected.edges.map(edgeKey).sort(), "same edge set");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("sequenceIssues: four issues behind a gate → the exact 13-node / 12-edge canonical chain", () => {
|
|
70
|
+
const behind = "acme/repo#100";
|
|
71
|
+
const issues = ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"];
|
|
72
|
+
const graph = ok({ behind, issues });
|
|
73
|
+
// The evidence-session shape: 1 gate + 3 nodes/issue = 13 nodes; 8 fact edges + 1 gate edge + 3
|
|
74
|
+
// sequence edges = 12 edges.
|
|
75
|
+
assertEquals(graph.nodes.length, 13);
|
|
76
|
+
assertEquals(graph.edges.length, 12);
|
|
77
|
+
assertEquivalent(graph, handAuthored(behind, issues));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("sequenceIssues: without `behind`, no leading epic gate and no gate edge", () => {
|
|
81
|
+
const issues = ["acme/repo#1", "acme/repo#2"];
|
|
82
|
+
const graph = ok({ issues });
|
|
83
|
+
assertEquals(graph.nodes.length, 6);
|
|
84
|
+
assertEquals(graph.edges.length, 5); // 2 fact edges/issue (4) + 1 sequence edge
|
|
85
|
+
assert(!graph.nodes.some((n) => n.id === "gate-epic"), "no epic gate without `behind`");
|
|
86
|
+
assertEquivalent(graph, handAuthored(null, issues));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connector(converge-merge) → wait[pr,merged]", () => {
|
|
90
|
+
const graph = ok({ issues: ["acme/repo#7"] });
|
|
91
|
+
const open = graph.nodes.find((n) => n.id === "open-1");
|
|
92
|
+
const land = graph.nodes.find((n) => n.id === "land-1");
|
|
93
|
+
const merged = graph.nodes.find((n) => n.id === "merged-1");
|
|
94
|
+
assertEquals(open.kind, "agent");
|
|
95
|
+
assertEquals(open.agent.jobType, "senior:feature");
|
|
96
|
+
assertEquals(open.emits, [{ name: "pr", type: "pr" }]);
|
|
97
|
+
assertEquals(land.kind, "connector");
|
|
98
|
+
assertEquals(land.connector.target, "converge-merge");
|
|
99
|
+
assertEquals(land.connector.payload, { pr: "open-1.pr" });
|
|
100
|
+
assertEquals(merged.kind, "wait");
|
|
101
|
+
assertEquals(merged.wait.kind, "pr");
|
|
102
|
+
assertEquals(merged.wait.target, "open-1.pr");
|
|
103
|
+
assertEquals(merged.wait.match, { prState: "merged" });
|
|
104
|
+
// The pr fact is threaded to BOTH consumers by fact-qualified edges (§9.4).
|
|
105
|
+
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "land-1"));
|
|
106
|
+
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
|
|
110
|
+
const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
|
|
111
|
+
const gate = graph.nodes.find((n) => n.id === "gate-epic");
|
|
112
|
+
const merged = graph.nodes.find((n) => n.id === "merged-1");
|
|
113
|
+
assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
114
|
+
assertEquals(merged.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
115
|
+
assert(merged.wait.poll.timeoutMs > 30 * 60 * 1000, "budget must exceed the 30-minute default");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("sequenceIssues: the generated graph passes validateDeliveryGraph AND compiles", async () => {
|
|
119
|
+
const graph = ok({ behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"] });
|
|
120
|
+
assertEquals(validateDeliveryGraph(graph), []);
|
|
121
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
122
|
+
assert(compiled.ok, `expected the generated graph to compile, got ${JSON.stringify(compiled)}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("sequenceIssues: an issue URL is accepted and normalised to owner/repo#N", () => {
|
|
126
|
+
const graph = ok({ issues: ["https://github.com/acme/repo/issues/42"] });
|
|
127
|
+
const open = graph.nodes.find((n) => n.id === "open-1");
|
|
128
|
+
assertEquals(open.agent.prompt, "Implement acme/repo#42 and open a PR.");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Validation: the uniform issues[{path,message}] contract ─────────────────────────────────────
|
|
132
|
+
function rejects(intent: unknown): Array<{ path: string; message: string }> {
|
|
133
|
+
const res = buildSequenceGraph(intent);
|
|
134
|
+
assert(!res.ok, `expected rejection, got ${JSON.stringify(res)}`);
|
|
135
|
+
assert(Array.isArray(res.issues) && res.issues.length > 0, "issues[] must be non-empty");
|
|
136
|
+
for (const iss of res.issues) {
|
|
137
|
+
assert(typeof iss.path === "string" && typeof iss.message === "string", `bad issue ${JSON.stringify(iss)}`);
|
|
138
|
+
}
|
|
139
|
+
return res.issues;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("sequenceIssues: empty issues → rejected with issues[{path,message}]", () => {
|
|
143
|
+
const issues = rejects({ issues: [] });
|
|
144
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("sequenceIssues: missing issues → rejected", () => {
|
|
148
|
+
const issues = rejects({});
|
|
149
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("sequenceIssues: an unparseable issue ref → rejected at the offending index", () => {
|
|
153
|
+
const issues = rejects({ issues: ["acme/repo#1", "not-an-issue"] });
|
|
154
|
+
assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("sequenceIssues: an unparseable `behind` ref → rejected at behind", () => {
|
|
158
|
+
const issues = rejects({ behind: "nope", issues: ["acme/repo#1"] });
|
|
159
|
+
assert(issues.some((i) => i.path === "behind"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("sequenceIssues: an empty-string `behind` → rejected at behind (not silently ungated)", () => {
|
|
163
|
+
const issues = rejects({ behind: "", issues: ["acme/repo#1"] });
|
|
164
|
+
assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("sequenceIssues: a non-positive issue number (`#0`) → rejected at the offending index", () => {
|
|
168
|
+
const issues = rejects({ issues: ["acme/repo#0"] });
|
|
169
|
+
assert(issues.some((i) => i.path === "issues[0]"), `expected issues[0] path, got ${JSON.stringify(issues)}`);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sequenceIssues: an unsafe-integer issue number → rejected at the offending index", () => {
|
|
173
|
+
const issues = rejects({ issues: ["acme/repo#1", "acme/repo#99999999999999999999"] });
|
|
174
|
+
assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("sequenceIssues: a non-positive `behind` number (`#0`) → rejected at behind", () => {
|
|
178
|
+
const issues = rejects({ behind: "acme/repo#0", issues: ["acme/repo#1"] });
|
|
179
|
+
assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("sequenceIssues: more than the max issues → rejected", () => {
|
|
183
|
+
const many = Array.from({ length: MAX_SEQUENCE_ISSUES + 1 }, (_, i) => `acme/repo#${i + 1}`);
|
|
184
|
+
const issues = rejects({ issues: many });
|
|
185
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
186
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// nano-workforce — the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). ADR 0005's delivery graph is a closed vocabulary, and §9.4 of the
|
|
3
|
+
// operator guide already names the canonical shape for "implement issue → converge → merge". But an
|
|
4
|
+
// agent still had to hand-author the full node/edge JSON: in the evidence session, sequencing four
|
|
5
|
+
// issues behind a gate meant constructing 13 nodes and 12 edges by hand. This module produces that
|
|
6
|
+
// exact shape from a high-level INTENT instead.
|
|
7
|
+
//
|
|
8
|
+
// The intent is `{ behind?: "owner/repo#NN", issues: ["owner/repo#A", …] }`. For each issue it emits
|
|
9
|
+
// the canonical chain — `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`,
|
|
10
|
+
// late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`) — and threads the `pr`
|
|
11
|
+
// fact along fact-qualified edges per §9.4. The issues run in SEQUENCE: each issue's agent starts once
|
|
12
|
+
// the PRIOR issue has merged. An optional leading `wait[epic]` gate (when `behind` is given) makes the
|
|
13
|
+
// whole sequence wait for that issue/epic/feature to be fully merged first (§9.5).
|
|
14
|
+
//
|
|
15
|
+
// It is a PURE builder: no I/O, no staging. The operation (`operations/sequenceIssues.ts`) hands the
|
|
16
|
+
// constructed `DeliveryGraph` to the SAME `compileAndStageDeliveryGraph` flow the raw
|
|
17
|
+
// `compileDeliveryGraph` door uses (one compiler, one staging path — AGENTS.md "no drift surfaces").
|
|
18
|
+
// The generated graph is authored to pass `validateDeliveryGraph` by construction: unique node ids,
|
|
19
|
+
// `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
|
|
20
|
+
// (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
|
|
21
|
+
// `issues[{path,message}]` rather than only surfacing at compile time.
|
|
22
|
+
import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
23
|
+
import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
|
|
24
|
+
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
25
|
+
import { type ParsedIssue, parseIssue } from "./plan.ts";
|
|
26
|
+
|
|
27
|
+
/** A path-qualified validation failure — the uniform door error contract `issues[{path,message}]`
|
|
28
|
+
* (the same shape the runtime request-validator and the S1 harness assert). */
|
|
29
|
+
export interface SequenceIssueError {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`. */
|
|
35
|
+
export interface SequenceIssuesIntent {
|
|
36
|
+
behind?: string;
|
|
37
|
+
issues: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
|
|
41
|
+
* input/vocabulary rejections. */
|
|
42
|
+
export type SequenceIssuesResult =
|
|
43
|
+
| { ok: true; graph: DeliveryGraph }
|
|
44
|
+
| { ok: false; issues: SequenceIssueError[] };
|
|
45
|
+
|
|
46
|
+
/** The realistic merge-gate poll budget the canonical shape uses (§9.4 / §9.1): re-probe every 5
|
|
47
|
+
* minutes, budget 3 days. A `wait[pr, merged]` / `wait[epic]` waits on a human-paced merge, so the
|
|
48
|
+
* 30-minute default poll budget is a trap — always set an explicit `poll.timeoutMs` on a merge/epic
|
|
49
|
+
* gate. Exported so the regression test asserts the generated gate against the canonical values. */
|
|
50
|
+
export const MERGE_POLL = { everyMs: 300_000, timeoutMs: 259_200_000 } as const;
|
|
51
|
+
|
|
52
|
+
/** The wait-probe kind that observes a single in-flight PR's merge state (ADR 0005 §2). */
|
|
53
|
+
const PR_PROBE_KIND = "pr";
|
|
54
|
+
/** The wait-probe kind that observes a whole epic/feature lineage reaching "fully merged" (§9.5). */
|
|
55
|
+
const EPIC_PROBE_KIND = "epic";
|
|
56
|
+
/** The agent job type each issue node runs (a full single-issue feature implementation). */
|
|
57
|
+
const AGENT_JOB_TYPE = "senior:feature";
|
|
58
|
+
/** The upper bound on issues in one sequence — keeps the generated graph within the compiler's
|
|
59
|
+
* 256-node ceiling (3 nodes/issue + an optional gate) with generous headroom, and bounds the intent. */
|
|
60
|
+
export const MAX_SEQUENCE_ISSUES = 64;
|
|
61
|
+
|
|
62
|
+
/** A `pr`-typed emit declaration — what an `agent` node publishes for the PR it opened, so the
|
|
63
|
+
* downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
|
|
64
|
+
const PR_EMIT = { name: "pr", type: "pr" as const };
|
|
65
|
+
|
|
66
|
+
/** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
|
|
67
|
+
* `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
|
|
68
|
+
* past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
|
|
69
|
+
* gate on it would wait forever. Reject it deterministically at the door instead (Copilot review,
|
|
70
|
+
* PR #618), so only `#N` with `N >= 1 && Number.isSafeInteger(N)` is accepted. */
|
|
71
|
+
function parseIssueRef(ref: unknown): ParsedIssue | null {
|
|
72
|
+
const parsed = typeof ref === "string" ? parseIssue(ref) : null;
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
|
|
79
|
+
* `issues[{path,message}]` rejections for invalid input. Validates:
|
|
80
|
+
* - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
|
|
81
|
+
* - every `issues[i]` and the optional `behind` parse as an `owner/repo#N` reference;
|
|
82
|
+
* - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard).
|
|
83
|
+
* Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
|
|
84
|
+
*/
|
|
85
|
+
export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
86
|
+
const issues: SequenceIssueError[] = [];
|
|
87
|
+
|
|
88
|
+
const body = isRecord(intent) ? intent : {};
|
|
89
|
+
const rawIssues = body.issues;
|
|
90
|
+
const rawBehind = body.behind;
|
|
91
|
+
|
|
92
|
+
// ── `issues`: a non-empty, bounded array of parseable refs ───────────────────────────────────
|
|
93
|
+
const parsedIssues: string[] = [];
|
|
94
|
+
if (!Array.isArray(rawIssues)) {
|
|
95
|
+
issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
|
|
96
|
+
} else if (rawIssues.length === 0) {
|
|
97
|
+
issues.push({ path: "issues", message: "`issues` must contain at least one `owner/repo#N` issue reference." });
|
|
98
|
+
} else if (rawIssues.length > MAX_SEQUENCE_ISSUES) {
|
|
99
|
+
issues.push({
|
|
100
|
+
path: "issues",
|
|
101
|
+
message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
rawIssues.forEach((ref, i) => {
|
|
105
|
+
const parsed = parseIssueRef(ref);
|
|
106
|
+
if (!parsed) {
|
|
107
|
+
issues.push({
|
|
108
|
+
path: `issues[${i}]`,
|
|
109
|
+
message: `\`${String(ref)}\` is not a valid \`owner/repo#N\` issue reference.`,
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
parsedIssues.push(parsed.planKey);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── `behind` (optional): a parseable ref, when PRESENT ───────────────────────────────────────
|
|
118
|
+
// Only an OMITTED gate (`undefined`/`null`) is "no gate"; a present-but-empty `behind: ""` is a
|
|
119
|
+
// caller mistake (the schema requires `minLength: 1`), not an ungated sequence — reject it rather
|
|
120
|
+
// than silently dropping the gate the caller asked for (matters most when this builder is invoked
|
|
121
|
+
// directly, bypassing OpenAPI validation).
|
|
122
|
+
let behindKey: string | null = null;
|
|
123
|
+
if (rawBehind !== undefined && rawBehind !== null) {
|
|
124
|
+
const parsed = parseIssueRef(rawBehind);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
issues.push({
|
|
127
|
+
path: "behind",
|
|
128
|
+
message: `\`${String(rawBehind)}\` is not a valid \`owner/repo#N\` issue/epic reference.`,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
behindKey = parsed.planKey;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
|
|
136
|
+
// structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
|
|
137
|
+
// surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
|
|
138
|
+
const vocab = deliveryGraphVocabulary();
|
|
139
|
+
const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
|
|
140
|
+
if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
|
|
141
|
+
issues.push({
|
|
142
|
+
path: "issues",
|
|
143
|
+
message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
147
|
+
for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
|
|
148
|
+
if (!probeKinds.has(kind)) {
|
|
149
|
+
issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
154
|
+
|
|
155
|
+
return { ok: true, graph: assembleGraph(parsedIssues, behindKey) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Assemble the canonical node/edge chain for the (already-validated) issue keys + optional gate. */
|
|
159
|
+
function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryGraph {
|
|
160
|
+
const nodes: DeliveryNode[] = [];
|
|
161
|
+
const edges: DeliveryEdge[] = [];
|
|
162
|
+
|
|
163
|
+
// Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
|
|
164
|
+
const GATE_ID = "gate-epic";
|
|
165
|
+
if (behindKey) {
|
|
166
|
+
nodes.push({
|
|
167
|
+
id: GATE_ID,
|
|
168
|
+
kind: "wait",
|
|
169
|
+
wait: {
|
|
170
|
+
kind: EPIC_PROBE_KIND,
|
|
171
|
+
target: behindKey,
|
|
172
|
+
match: { epicState: "merged" },
|
|
173
|
+
poll: { ...MERGE_POLL },
|
|
174
|
+
onTimeout: "escalate",
|
|
175
|
+
},
|
|
176
|
+
emits: [{ name: "prCount", type: "number" }],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
issueKeys.forEach((issueKey, i) => {
|
|
181
|
+
const n = i + 1;
|
|
182
|
+
const openId = `open-${n}`;
|
|
183
|
+
const landId = `land-${n}`;
|
|
184
|
+
const mergedId = `merged-${n}`;
|
|
185
|
+
const prRef = `${openId}.pr`;
|
|
186
|
+
|
|
187
|
+
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
|
|
188
|
+
nodes.push({
|
|
189
|
+
id: openId,
|
|
190
|
+
kind: "agent",
|
|
191
|
+
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.` },
|
|
192
|
+
emits: [{ ...PR_EMIT }],
|
|
193
|
+
});
|
|
194
|
+
// connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
|
|
195
|
+
nodes.push({
|
|
196
|
+
id: landId,
|
|
197
|
+
kind: "connector",
|
|
198
|
+
connector: { target: CONVERGE_MERGE_TARGET, payload: { pr: prRef } },
|
|
199
|
+
});
|
|
200
|
+
// wait[pr, merged] → observe the PR reaching `merged`, with a realistic poll budget.
|
|
201
|
+
nodes.push({
|
|
202
|
+
id: mergedId,
|
|
203
|
+
kind: "wait",
|
|
204
|
+
wait: {
|
|
205
|
+
kind: PR_PROBE_KIND,
|
|
206
|
+
target: prRef,
|
|
207
|
+
match: { prState: "merged" },
|
|
208
|
+
poll: { ...MERGE_POLL },
|
|
209
|
+
onTimeout: "escalate",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Thread the `pr` fact to both consumers (required — an unthreaded reference is `unbound-pr`).
|
|
214
|
+
edges.push({ from: prRef, to: landId });
|
|
215
|
+
edges.push({ from: prRef, to: mergedId });
|
|
216
|
+
|
|
217
|
+
// Sequence: this issue's agent starts once the PRIOR issue merged; the first waits on the gate.
|
|
218
|
+
if (i === 0) {
|
|
219
|
+
if (behindKey) edges.push({ from: GATE_ID, to: openId });
|
|
220
|
+
} else {
|
|
221
|
+
edges.push({ from: `merged-${i}`, to: openId });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const name =
|
|
226
|
+
issueKeys.length === 1
|
|
227
|
+
? `sequence ${issueKeys[0]}`
|
|
228
|
+
: `sequence ${issueKeys.length} issues${behindKey ? ` behind ${behindKey}` : ""}`;
|
|
229
|
+
|
|
230
|
+
return { name, nodes, edges };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
|
|
234
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
235
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
236
|
+
}
|
package/app/service.ts
CHANGED
|
@@ -193,7 +193,7 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
193
193
|
// The `senior:pr-review` agent prompt is no longer read by the host: it is a generic resource
|
|
194
194
|
// (`resources/prompts/review-round.md`, deployed under the `resources/` deploy-by-convention layout
|
|
195
195
|
// — nano.app.json declares no `models`) linked into the task
|
|
196
|
-
// as `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`
|
|
196
|
+
// as `<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" linkName="prompt"/>`
|
|
197
197
|
// and resolved by the engine at job activation. The host only carries runtime PR identity + the
|
|
198
198
|
// round counter now.
|
|
199
199
|
|