@nanobpm/nano-workforce 0.200.4 → 0.200.5
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 +6 -0
- package/SPEC.md +33 -0
- package/app/deliveryGraph.test.ts +1750 -0
- package/app/deliveryGraph.ts +1277 -1
- package/package.json +1 -1
- package/resources/prompts/feature.md +11 -0
- package/resources/prompts/plan.md +61 -4
- package/resources/prompts/scope-classify.md +35 -2
- package/scripts/scope-closing-keyword.test.ts +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.5",
|
|
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",
|
|
@@ -133,6 +133,17 @@ you on the right side of it:
|
|
|
133
133
|
(`Refs #N` / `Part of #N`) and **leave #N open** (or convert #N into a
|
|
134
134
|
tracking/umbrella issue for the remainder). The classifier flags a PR that
|
|
135
135
|
closes #N while leaving part of #N's stated scope undelivered and untracked.
|
|
136
|
+
**This rule governs your COMMIT MESSAGES and your PR TITLE too, not just the PR
|
|
137
|
+
body.** This repo family squash-merges with `COMMIT_MESSAGES`, so a `Closes #N`
|
|
138
|
+
in **any commit body** closes the issue on merge — even after you correct the PR
|
|
139
|
+
body to `Part of #N`. The **PR title becomes the squash commit SUBJECT**, so a
|
|
140
|
+
closing keyword in the title (`fix: parser slice (Closes #N)`) likewise closes #N
|
|
141
|
+
on merge. A partial slice whose PR body says `Part of #N` but whose first commit —
|
|
142
|
+
or whose title — still carries `Closes #N` *still* closes the broader parent on
|
|
143
|
+
merge, and the scope gate scans the title and commit bodies as well as the PR
|
|
144
|
+
body. So when you split scope, write `Part of #N` (never a closing keyword) in the
|
|
145
|
+
PR **title**, the PR body, and every commit body; if an early commit already
|
|
146
|
+
closed it, amend/reword that commit before you push.
|
|
136
147
|
2. **A deferred remainder must be a FILED, tracked issue — never just prose.** If
|
|
137
148
|
your PR defers part of its scope, **file a follow-up issue for each deferred
|
|
138
149
|
item** and link it in the PR body with an explicit tracking marker the classifier
|
|
@@ -84,10 +84,15 @@ For every distinct child issue number `N` you find:
|
|
|
84
84
|
- Otherwise, emit **one task** for it (see the output contract), with:
|
|
85
85
|
- `id` = `issue-N`,
|
|
86
86
|
- `title` = the sub-issue's title,
|
|
87
|
-
- `prompt` = a self-contained brief built from the sub-issue's body
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
- `prompt` = a self-contained brief built from the sub-issue's body carrying
|
|
88
|
+
**#N's FULL stated scope** (every acceptance criterion / checkbox of the
|
|
89
|
+
sub-issue — an adopted sub-issue task owns its whole sub-issue, so the brief
|
|
90
|
+
must not silently cover only part of it), and end the prompt with an explicit
|
|
91
|
+
instruction to the implementing agent to open its PR against this specific
|
|
92
|
+
sub-issue and include `Closes #N` in the PR body so the sub-issue is linked and
|
|
93
|
+
auto-closed on merge. A closing keyword is only correct because the task
|
|
94
|
+
delivers #N in full; **never pair `Closes #N` with a brief scoped to a subset
|
|
95
|
+
of #N** (see "Closing keywords" below).
|
|
91
96
|
- `dependsOn` = **honour any inter-sub-issue ordering the human declared.** Scan
|
|
92
97
|
the sub-issue's body for an explicit dependency directive — a line such as
|
|
93
98
|
`Depends-on: #7`, `Depends on #7`, or `Blocked by #7` (case-insensitive; there
|
|
@@ -192,6 +197,58 @@ a cohesive published artifact at the same time. Reserve genuinely separate packa
|
|
|
192
197
|
for the consumer-facing cases above, and say in the task prompt which consumer
|
|
193
198
|
justifies the split.
|
|
194
199
|
|
|
200
|
+
### Closing keywords → only a FULL-scope slice may close its issue
|
|
201
|
+
|
|
202
|
+
When you decompose one issue into several slices, each slice delivers only **part**
|
|
203
|
+
of that issue's acceptance criteria. A slice scoped to a subset of an issue's scope
|
|
204
|
+
**must not be told to close that issue** — that is the exact planning defect this
|
|
205
|
+
rule exists to prevent (a node scoped to one of an issue's three acceptance
|
|
206
|
+
criteria was told to "close" it, the remainder was lost, and a human had to file
|
|
207
|
+
the follow-up and rewrite the PR by hand).
|
|
208
|
+
|
|
209
|
+
So, when you author each slice's `prompt`:
|
|
210
|
+
|
|
211
|
+
- **Only instruct a closing keyword (`Closes/Fixes/Resolves #N`) in a slice whose
|
|
212
|
+
brief delivers #N's FULL stated scope — and say so in an issue-anchored
|
|
213
|
+
whole-scope phrase the deterministic validator recognises.** If exactly one
|
|
214
|
+
slice covers the whole issue (the common single-task case), it may close it. If
|
|
215
|
+
you split the issue across slices, the parent issue is **broader** than any one
|
|
216
|
+
slice — so **no slice** closes it with a bare brief: instruct each partial slice
|
|
217
|
+
to reference the parent non-blockingly (`Part of #N` / `Refs #N`) and **leave
|
|
218
|
+
#N open**, OR designate one final slice that carries the `Closes #N`. That final
|
|
219
|
+
slice is the exception only when its brief makes it **own and verify #N's
|
|
220
|
+
COMPLETE stated scope** — every acceptance criterion, not just the remainder.
|
|
221
|
+
`dependsOn` on the sibling slices orders the merges, but it does **not** by
|
|
222
|
+
itself make the final slice's brief full-scope: a final slice scoped only to
|
|
223
|
+
"the remaining criterion" is still a partial brief and must **not** close #N.
|
|
224
|
+
Scope the final closer to re-verify the whole issue (its brief must direct it to
|
|
225
|
+
check every checkbox and confirm the siblings' merged work satisfies them)
|
|
226
|
+
before it emits `Closes #N`.
|
|
227
|
+
**The validator (`validateDeliveryGraph`) enforces this deterministically:** an
|
|
228
|
+
`agent.prompt` that closes an issue is rejected unless the brief also carries an
|
|
229
|
+
explicit **issue-anchored whole-scope acknowledgement TIED TO THE ISSUE IT
|
|
230
|
+
CLOSES**. Anchor the acknowledgement to the **same** issue — e.g. "delivers #N's
|
|
231
|
+
full stated scope", "every acceptance criterion of #N", "all of #N", "the whole
|
|
232
|
+
issue #N". A marker that names a **different** issue than the one you close
|
|
233
|
+
(acknowledge #11's scope, but `Closes #12`), or one that attributes the scope to
|
|
234
|
+
**siblings/other slices** ("the full scope of #N is handled by siblings"), does
|
|
235
|
+
**not** count — the validator credits a whole-scope phrase to issue #N only when it
|
|
236
|
+
sits in the same clause as #N (or when the prompt references exactly one issue) and
|
|
237
|
+
is not attributed to others. When a prompt closes more than one issue, give **each**
|
|
238
|
+
closing target its own anchored acknowledgement. A **bare adverb of completeness does not count** —
|
|
239
|
+
"fully", "completely", "end-to-end", "in full", "in its entirety" can modify a
|
|
240
|
+
*partial* deliverable ("implement one criterion fully"), so the validator
|
|
241
|
+
ignores them; always name the issue's whole scope, not just an adverb.
|
|
242
|
+
- **The remainder must be tracked, never dropped.** Every acceptance criterion of
|
|
243
|
+
the split issue must be covered by **some** slice in this plan (collectively the
|
|
244
|
+
slices must deliver #N's whole scope). Do not emit a slice set that silently
|
|
245
|
+
under-delivers the parent while one slice closes it.
|
|
246
|
+
- **This governs the COMMIT MESSAGES you tell the agent to write, too.** This repo
|
|
247
|
+
family squash-merges with `COMMIT_MESSAGES`, so a `Closes #N` in **any commit
|
|
248
|
+
body** closes the issue on merge even if the PR body says `Part of #N`. When a
|
|
249
|
+
slice is partial, instruct the agent to write `Part of #N` (never a closing
|
|
250
|
+
keyword) in **both** the PR body and every commit body.
|
|
251
|
+
|
|
195
252
|
## Output contract
|
|
196
253
|
|
|
197
254
|
Write a JSON object of **result variables** to the file named by the
|
|
@@ -46,13 +46,46 @@ result.
|
|
|
46
46
|
|
|
47
47
|
## What to do
|
|
48
48
|
|
|
49
|
-
1. **Read the PR body
|
|
49
|
+
1. **Read the PR body, the PR TITLE, AND the commit bodies.**
|
|
50
|
+
`gh pr view <prNumber> --repo <repo> --json body,title`.
|
|
50
51
|
Extract every issue the body **closes with a GitHub closing keyword** —
|
|
51
52
|
`close/closes/closed`, `fix/fixes/fixed`, `resolve/resolves/resolved` followed by
|
|
52
|
-
`#N`, `owner/repo#N`, or a full issue URL.
|
|
53
|
+
`#N`, `owner/repo#N`, or a full issue URL. GitHub also recognises an **optional colon**
|
|
54
|
+
between the keyword and its target (`Closes: #12`, `Fixes: owner/repo#12`), so treat the
|
|
55
|
+
colon form as a closing reference too. A **non-closing** reference (`Refs #N`,
|
|
53
56
|
`Part of #N`, `Depends-on #N`, `Follow-up: #N`) does **not** close an issue —
|
|
54
57
|
ignore those for the closing-scope check (but note the follow-up links; see below).
|
|
55
58
|
|
|
59
|
+
**Scan the PR TITLE with these exact same closing-keyword rules too.** This repo
|
|
60
|
+
family squash-merges, so the **PR title becomes the squash commit SUBJECT**
|
|
61
|
+
(`AGENTS.md` "PR titles must be Conventional too — they become the release trigger"),
|
|
62
|
+
and GitHub honours a closing keyword in the squash subject exactly like one in the
|
|
63
|
+
body or a commit. So a conventional title such as `fix: parser slice (Closes #12)`
|
|
64
|
+
**closes #12 on merge** even when the body only says `Part of #12` and every commit
|
|
65
|
+
is clean. Treat a closing keyword found in the **title** exactly like one in the PR
|
|
66
|
+
body — add its `#N` to the set of closed issues you judge below (and if the slice is
|
|
67
|
+
partial, tell the human to reword the title to a non-closing form).
|
|
68
|
+
|
|
69
|
+
**Then scan the PR's commit messages for the same closing keywords.** Use the
|
|
70
|
+
**full commit history** — fetch base and head, then
|
|
71
|
+
`git log --format='%H%n%B' origin/<base>..<head>` (or `git log origin/<base>..<head>`).
|
|
72
|
+
**Do NOT rely on `gh pr view <prNumber> --repo <repo> --json commits` for this:**
|
|
73
|
+
the GitHub CLI silently truncates the `commits` field at **100 entries**, so on a
|
|
74
|
+
101–250-commit PR it can miss an offending `Closes #N` in a later commit and
|
|
75
|
+
incorrectly clear the scope gate. If you must use the API instead of `git log`,
|
|
76
|
+
page it explicitly (`gh api --paginate .../pulls/<prNumber>/commits`) rather than
|
|
77
|
+
the capped `--json commits`. Likewise do **not** use `gh pr diff` here — it returns
|
|
78
|
+
only the patch, not the commit subjects/bodies, so it cannot detect a closing
|
|
79
|
+
keyword and would incorrectly clear the PR. This matters because this repo
|
|
80
|
+
family **squash-merges with `COMMIT_MESSAGES`**: the squash commit concatenates
|
|
81
|
+
every commit body, so a `Closes #N` left in **any commit body closes #N on
|
|
82
|
+
merge** even when the PR body was later corrected to `Part of #N`. Treat a
|
|
83
|
+
closing keyword found in a commit body **exactly** like one in the PR body — add
|
|
84
|
+
its `#N` to the set of closed issues you judge below. A PR whose body says
|
|
85
|
+
`Part of #N` but whose commit body still carries `Closes #N`, while part of #N's
|
|
86
|
+
scope is undelivered and untracked, is a genuine under-delivery you must block
|
|
87
|
+
(name the offending commit and tell the human to reword/amend it to `Part of #N`).
|
|
88
|
+
|
|
56
89
|
2. **If there are no closing-keyword issues, the PR closes nothing** — there is no
|
|
57
90
|
broader-scoped parent to under-deliver. Return **`scopeBlocked: false`** and stop.
|
|
58
91
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Guard for issue #858: a planner must not scope an agent node to PART of an issue while still
|
|
2
|
+
// telling it to CLOSE that issue, and the scope gate + agent contract must cover COMMIT bodies.
|
|
3
|
+
//
|
|
4
|
+
// Field case (merlin, delivery graph `5e36636255ab`): node `i12`'s prompt said "implement
|
|
5
|
+
// nano-supervisor#12 and open a PR … that closes it", but the body only described one of #12's
|
|
6
|
+
// three acceptance criteria. The agent did exactly that and wrote `Closes #12` in BOTH the PR body
|
|
7
|
+
// and the commit body. The convergence scope gate blocked, but a human still had to file the
|
|
8
|
+
// follow-up and rewrite the PR. The defect was in planning: a node scoped narrower than its issue
|
|
9
|
+
// must not be told to close it. The repo squash-merges with `COMMIT_MESSAGES`, so a `Closes #N` in a
|
|
10
|
+
// commit body closes the issue on merge even after the PR body is corrected — so the agent contract,
|
|
11
|
+
// the planner, and the scope gate must all reason about commit bodies, not just the PR body.
|
|
12
|
+
//
|
|
13
|
+
// These are prompt CONTRACTS (not code paths): the planner (`plan.md`), the implementation agent
|
|
14
|
+
// (`feature.md`), and the scope gate (`scope-classify.md`) are all prompt-driven. This test pins the
|
|
15
|
+
// guidance so a future prompt edit cannot silently drop the guard that closes this failure class.
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import { assert } from "#test-assert";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const PROMPTS_DIR = join(import.meta.dirname, "..", "resources", "prompts");
|
|
22
|
+
const read = (name: string): string => readFileSync(join(PROMPTS_DIR, name), "utf8");
|
|
23
|
+
|
|
24
|
+
// The GitHub closing keywords, as the gate/agent must recognise them. A "closing keyword" in prose
|
|
25
|
+
// is any of these; a non-closing ref (`Part of`, `Refs`, `Follow-up`) is explicitly NOT.
|
|
26
|
+
const CLOSING_KEYWORDS = ["close", "fix", "resolve"];
|
|
27
|
+
|
|
28
|
+
// The closing-keyword SYNTAX each prompt must document for EVERY verb family — the canonical
|
|
29
|
+
// instruction triplet `Closes/Fixes/Resolves` (or the inflected `close/closes/closed …
|
|
30
|
+
// fix/fixes/fixed … resolve/resolves/resolved` listing). Asserting the syntax (not a bare
|
|
31
|
+
// `includes("fix")`, which passes for unrelated reasons) means dropping a family from a contract —
|
|
32
|
+
// e.g. removing `Fixes` — turns the test red instead of leaving it green.
|
|
33
|
+
const CLOSING_SYNTAX = [
|
|
34
|
+
/closes?\/fixes?\/resolves?/i,
|
|
35
|
+
/close\/closes\/closed[\s\S]*fix\/fixes\/fixed[\s\S]*resolve\/resolves\/resolved/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
test("feature.md: the closing-keyword rule covers COMMIT MESSAGES, not just the PR body", () => {
|
|
39
|
+
const feature = read("feature.md").toLowerCase();
|
|
40
|
+
// The agent contract must tell the agent a closing keyword in a commit body closes the issue on
|
|
41
|
+
// merge (the COMMIT_MESSAGES squash concern), so a partial slice must write `Part of #N` in both.
|
|
42
|
+
assert(
|
|
43
|
+
feature.includes("commit messages") || feature.includes("commit body") || feature.includes("commit bodies"),
|
|
44
|
+
"feature.md must tie the closing-keyword rule to commit messages/bodies",
|
|
45
|
+
);
|
|
46
|
+
assert(feature.includes("commit_messages"), "feature.md must name the COMMIT_MESSAGES squash behaviour");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("plan.md: a partial slice must not be told to close its broader-scoped parent issue", () => {
|
|
50
|
+
const plan = read("plan.md");
|
|
51
|
+
const lower = plan.toLowerCase();
|
|
52
|
+
// The decomposition step must carry explicit closing-keyword guidance for split issues.
|
|
53
|
+
assert(lower.includes("closing keyword"), "plan.md must discuss closing keywords for slices");
|
|
54
|
+
// A full-scope requirement must gate a closing keyword, and a partial slice must use `Part of #N`.
|
|
55
|
+
assert(lower.includes("full"), "plan.md must require FULL scope before a slice closes an issue");
|
|
56
|
+
assert(plan.includes("Part of #N"), "plan.md must steer a partial slice to `Part of #N`");
|
|
57
|
+
// The planner guidance must also reach commit messages (the COMMIT_MESSAGES squash concern).
|
|
58
|
+
assert(lower.includes("commit_messages") || lower.includes("commit bod"), "plan.md must cover commit bodies");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("scope-classify.md: the scope gate scans COMMIT bodies for closing keywords too", () => {
|
|
62
|
+
const gate = read("scope-classify.md");
|
|
63
|
+
const lower = gate.toLowerCase();
|
|
64
|
+
// The gate must read the PR's commits (not only its body) for closing keywords.
|
|
65
|
+
assert(lower.includes("commit"), "scope-classify.md must mention commits");
|
|
66
|
+
// It must name the commit body/message as the thing to scan…
|
|
67
|
+
assert(
|
|
68
|
+
lower.includes("commit bod") || lower.includes("commit message"),
|
|
69
|
+
"scope-classify.md must instruct reading commit bodies/messages",
|
|
70
|
+
);
|
|
71
|
+
// …AND prescribe an actual FULL-HISTORY retrieval mechanism the contract permits (`git log` or an
|
|
72
|
+
// explicitly `--paginate`d API call). Merely mentioning "commit bodies" — or the capped, explicitly
|
|
73
|
+
// FORBIDDEN `--json commits` — is not enough: deleting the real retrieval instruction would leave a
|
|
74
|
+
// substring-only check green while the gate silently loses its ability to see a `Closes #N` in a
|
|
75
|
+
// later commit of a >100-commit PR (issue #858 round-5 review).
|
|
76
|
+
assert(
|
|
77
|
+
/git\s+log/.test(lower) || lower.includes("--paginate"),
|
|
78
|
+
"scope-classify.md must prescribe a full-history commit retrieval (`git log` or a `--paginate`d " +
|
|
79
|
+
"API call), not the capped `--json commits`",
|
|
80
|
+
);
|
|
81
|
+
// It must explain WHY: the COMMIT_MESSAGES squash carries a commit-body closing keyword to merge.
|
|
82
|
+
assert(lower.includes("commit_messages"), "scope-classify.md must name the COMMIT_MESSAGES squash behaviour");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("scope-classify.md: the scope gate scans the PR TITLE for closing keywords (squash subject)", () => {
|
|
86
|
+
const gate = read("scope-classify.md");
|
|
87
|
+
const lower = gate.toLowerCase();
|
|
88
|
+
// A closing keyword in the PR title becomes the squash commit SUBJECT and closes the issue on merge,
|
|
89
|
+
// so the gate must scan the title with the same closing-keyword rules as the body/commits — not only
|
|
90
|
+
// fetch it (issue #858 round-16 review). Pin the instruction, not just the `--json body,title` fetch.
|
|
91
|
+
assert(lower.includes("title"), "scope-classify.md must reference the PR title");
|
|
92
|
+
assert(
|
|
93
|
+
/scan\s+the\s+pr\s+title|title[\s\S]{0,80}closing[- ]keyword|closing[- ]keyword[\s\S]{0,80}title/.test(lower),
|
|
94
|
+
"scope-classify.md must instruct scanning the PR TITLE with the closing-keyword rules",
|
|
95
|
+
);
|
|
96
|
+
// …and explain WHY the title matters: it becomes the squash commit subject.
|
|
97
|
+
assert(
|
|
98
|
+
/squash\s+(?:commit\s+)?subject|title\s+becomes\s+the\s+squash/.test(lower),
|
|
99
|
+
"scope-classify.md must explain the title becomes the squash commit subject",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("feature.md: the no-close-on-partial rule also governs the PR TITLE (squash subject)", () => {
|
|
104
|
+
const lower = read("feature.md").toLowerCase();
|
|
105
|
+
// The agent authors the PR title, so its contract must warn that a closing keyword there closes the
|
|
106
|
+
// issue on merge (title -> squash subject), not only the body/commit bodies (issue #858 round-16).
|
|
107
|
+
assert(lower.includes("title"), "feature.md must reference the PR title in the closing-keyword rule");
|
|
108
|
+
assert(
|
|
109
|
+
/title\s+becomes\s+the\s+squash|squash\s+(?:commit\s+)?subject/.test(lower),
|
|
110
|
+
"feature.md must tie the PR title to the squash commit subject",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("all three prompts recognise the full set of GitHub closing keywords", () => {
|
|
115
|
+
for (const name of ["feature.md", "plan.md", "scope-classify.md"]) {
|
|
116
|
+
const lower = read(name).toLowerCase();
|
|
117
|
+
for (const kw of CLOSING_KEYWORDS) {
|
|
118
|
+
assert(lower.includes(kw), `${name} must reference the closing keyword family "${kw}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Stronger than the substring check above: pin the closing-keyword SYNTAX in each prompt, so a future
|
|
124
|
+
// edit that drops a verb family from the contract (e.g. `Closes/Resolves` with `Fixes` removed) fails
|
|
125
|
+
// loudly instead of staying green because the word "fix" still appears somewhere unrelated.
|
|
126
|
+
test("all three prompts document the closing-keyword SYNTAX for every verb family (close/fix/resolve)", () => {
|
|
127
|
+
for (const name of ["feature.md", "plan.md", "scope-classify.md"]) {
|
|
128
|
+
const text = read(name);
|
|
129
|
+
assert(
|
|
130
|
+
CLOSING_SYNTAX.some((re) => re.test(text)),
|
|
131
|
+
`${name} must document the Closes/Fixes/Resolves closing-keyword triplet (or the inflected ` +
|
|
132
|
+
`close/closes/closed … listing), not merely the bare substrings`,
|
|
133
|
+
);
|
|
134
|
+
assert(
|
|
135
|
+
/\bcloses?\s+#(?:n|\d)/i.test(text),
|
|
136
|
+
`${name} must show the actionable \`Closes #N\` instruction form`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
});
|