@nanobpm/nano-workforce 0.92.0 → 0.93.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 +7 -0
- package/app/convergeGate.test.ts +80 -1
- package/app/scopeGuard.test.ts +147 -0
- package/app/scopeGuard.ts +131 -0
- package/package.json +1 -1
- package/resources/prompts/feature.md +30 -1
- package/workers/converge-gate/worker.ts +46 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.93.0](https://github.com/nanobpm/nano-workforce/compare/v0.92.0...v0.93.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* enforce scope-integrity guards in the review-convergence loop ([#314](https://github.com/nanobpm/nano-workforce/issues/314)) ([0a2a012](https://github.com/nanobpm/nano-workforce/commit/0a2a012b5d11e129de27633f1ef8508bc0fb457b)), closes [#631](https://github.com/nanobpm/nano-workforce/issues/631) [#863](https://github.com/nanobpm/nano-workforce/issues/863) [#872](https://github.com/nanobpm/nano-workforce/issues/872) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#313](https://github.com/nanobpm/nano-workforce/issues/313) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [owner/repo#N](https://github.com/owner/repo/issues/N)
|
|
7
|
+
|
|
1
8
|
# [0.92.0](https://github.com/nanobpm/nano-workforce/compare/v0.91.0...v0.92.0) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -217,9 +217,12 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
|
|
|
217
217
|
async function makeUnderTest(deps: {
|
|
218
218
|
readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
|
|
219
219
|
readReviewBody: (repo: string, n: number) => Promise<string | null>;
|
|
220
|
+
readPrBody?: (repo: string, n: number) => Promise<string | null>;
|
|
220
221
|
}) {
|
|
221
222
|
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
222
|
-
|
|
223
|
+
// Default the scope-guard PR-body read to a verified-empty description so the comment-gate tests
|
|
224
|
+
// below exercise only the review-comment dimension; scope-guard tests pass an explicit body.
|
|
225
|
+
return makeHandler({ readPrBody: async () => "", ...deps });
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
@@ -342,6 +345,82 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
|
|
|
342
345
|
assertEquals(seen, ["o/r", 7]);
|
|
343
346
|
});
|
|
344
347
|
|
|
348
|
+
// ── The scope-integrity guard through the worker (#313) ─────────────────────
|
|
349
|
+
|
|
350
|
+
test("converge-gate: a partial delivery that Closes a broader-scoped parent blocks convergence", async () => {
|
|
351
|
+
const handler = await makeUnderTest({
|
|
352
|
+
readThreads: async () => [],
|
|
353
|
+
readReviewBody: async () => "",
|
|
354
|
+
readPrBody: async () =>
|
|
355
|
+
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
356
|
+
});
|
|
357
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
358
|
+
assertEquals(out.convergeBlocked, true);
|
|
359
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
360
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "#631");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("converge-gate: a deferral with a filed follow-up issue and a non-closing ref converges", async () => {
|
|
364
|
+
const handler = await makeUnderTest({
|
|
365
|
+
readThreads: async () => [],
|
|
366
|
+
readReviewBody: async () => "",
|
|
367
|
+
readPrBody: async () =>
|
|
368
|
+
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools are deferred.\nTracked-in: #872\n\nRefs #631",
|
|
369
|
+
});
|
|
370
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
371
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("converge-gate: a full-scope Closes PR with no deferral prose converges", async () => {
|
|
375
|
+
const handler = await makeUnderTest({
|
|
376
|
+
readThreads: async () => [],
|
|
377
|
+
readReviewBody: async () => "",
|
|
378
|
+
readPrBody: async () => "Implements the feature end to end.\n\nCloses #313",
|
|
379
|
+
});
|
|
380
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
381
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("converge-gate: a scope block and a comment block are reported together", async () => {
|
|
385
|
+
const handler = await makeUnderTest({
|
|
386
|
+
readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
|
|
387
|
+
readReviewBody: async () => "",
|
|
388
|
+
readPrBody: async () => "Ships one half.\n\nDeferred: the rest.\n\nCloses #631",
|
|
389
|
+
});
|
|
390
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
391
|
+
assertEquals(out.convergeBlocked, true);
|
|
392
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
|
|
393
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("converge-gate: FAILS CLOSED when the PR-body read returns null (no transport)", async () => {
|
|
397
|
+
const handler = await makeUnderTest({
|
|
398
|
+
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
399
|
+
readReviewBody: async () => "",
|
|
400
|
+
readPrBody: async () => null,
|
|
401
|
+
});
|
|
402
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
403
|
+
assertEquals(out.convergeBlocked, true);
|
|
404
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("converge-gate: FAILS CLOSED with the SCOPE reason when the PR-body read throws", async () => {
|
|
408
|
+
// A transport failure while reading/parsing the PR body is a scope-integrity read failure, not a
|
|
409
|
+
// review-comment verification failure: it must surface BLOCK_UNVERIFIABLE_BODY, not the generic
|
|
410
|
+
// review-comment BLOCK_UNVERIFIABLE — otherwise the human escalation is pointed at review threads
|
|
411
|
+
// when the real problem is the PR description could not be read.
|
|
412
|
+
const handler = await makeUnderTest({
|
|
413
|
+
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
414
|
+
readReviewBody: async () => "",
|
|
415
|
+
readPrBody: async () => {
|
|
416
|
+
throw new Error("boom");
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
420
|
+
assertEquals(out.convergeBlocked, true);
|
|
421
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
422
|
+
});
|
|
423
|
+
|
|
345
424
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
346
425
|
|
|
347
426
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Scope-integrity guard — unit tests for the canonical router (app/scopeGuard.ts) and its parsing
|
|
2
|
+
// helpers.
|
|
3
|
+
//
|
|
4
|
+
// A parity slice can be silently under-delivered: an agent splits a large slice, ships one half,
|
|
5
|
+
// then `Closes #N` a broader-scoped parent while recording the deferred remainder only in PR prose
|
|
6
|
+
// (a `## Scope` section) with no filed follow-up issue. Magikcraft/nano-bpm#631 → PR #863 did
|
|
7
|
+
// exactly this and the deferred half was lost until a human re-filed it as #872. These two guards
|
|
8
|
+
// (#313) block that class: a partial delivery may not close-keyword a broader-scoped parent, and any
|
|
9
|
+
// deferral must link a filed follow-up issue rather than live in prose.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
12
|
+
import {
|
|
13
|
+
evaluateScopeGuard,
|
|
14
|
+
findClosingKeywordRefs,
|
|
15
|
+
hasDeferralMarker,
|
|
16
|
+
hasFollowupIssueRef,
|
|
17
|
+
} from "./scopeGuard.ts";
|
|
18
|
+
|
|
19
|
+
// ── The canonical router ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
|
|
22
|
+
const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
|
|
23
|
+
assertEquals(r.scopeBlocked, false);
|
|
24
|
+
assertEquals(r.scopeBlockReason, "");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
|
|
28
|
+
const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
|
|
29
|
+
assertEquals(r.scopeBlocked, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
|
|
33
|
+
const r = evaluateScopeGuard({
|
|
34
|
+
prBody:
|
|
35
|
+
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
36
|
+
});
|
|
37
|
+
assertEquals(r.scopeBlocked, true);
|
|
38
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
39
|
+
assertStringIncludes(r.scopeBlockReason, "#631");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
|
|
43
|
+
const r = evaluateScopeGuard({
|
|
44
|
+
prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
|
|
45
|
+
});
|
|
46
|
+
assertEquals(r.scopeBlocked, true);
|
|
47
|
+
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
48
|
+
// Guard 1 must NOT fire — this PR correctly used a non-closing ref.
|
|
49
|
+
assert(
|
|
50
|
+
!r.scopeBlockReason.includes("must not close"),
|
|
51
|
+
"a non-closing ref must not trip the closing-keyword guard",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
|
|
56
|
+
const r = evaluateScopeGuard({
|
|
57
|
+
prBody:
|
|
58
|
+
"Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
|
|
59
|
+
});
|
|
60
|
+
assertEquals(r.scopeBlocked, false);
|
|
61
|
+
assertEquals(r.scopeBlockReason, "");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
|
|
65
|
+
const r = evaluateScopeGuard({
|
|
66
|
+
prBody:
|
|
67
|
+
"## Summary\nNested ad-hoc / agent-of-agents delivered.\n\n## Scope\nembedded `SUB_PROCESS` tools whose multi-element body runs by token flow remain the deferred refinement.\n\nCloses #631",
|
|
68
|
+
});
|
|
69
|
+
assertEquals(r.scopeBlocked, true);
|
|
70
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
71
|
+
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
|
|
75
|
+
// Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
|
|
76
|
+
const r = evaluateScopeGuard({
|
|
77
|
+
prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
|
|
78
|
+
});
|
|
79
|
+
assertEquals(r.scopeBlocked, true);
|
|
80
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
81
|
+
assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("evaluateScopeGuard: tolerates null / empty bodies", () => {
|
|
85
|
+
assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
|
|
86
|
+
assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
|
|
87
|
+
assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── The parsers ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
|
|
93
|
+
const body = [
|
|
94
|
+
"Closes #12",
|
|
95
|
+
"fixes: owner/repo#34",
|
|
96
|
+
"Resolved https://github.com/owner/repo/issues/56",
|
|
97
|
+
"Closes #12", // duplicate
|
|
98
|
+
].join("\n");
|
|
99
|
+
assertEquals(findClosingKeywordRefs(body), [
|
|
100
|
+
"#12",
|
|
101
|
+
"owner/repo#34",
|
|
102
|
+
"https://github.com/owner/repo/issues/56",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
|
|
107
|
+
assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
|
|
111
|
+
assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
|
|
112
|
+
assert(hasDeferralMarker("### scope of work"), "any heading level counts");
|
|
113
|
+
assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
|
|
114
|
+
assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
|
|
115
|
+
assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
|
|
116
|
+
assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
|
|
120
|
+
// "all done" phrasing must not be read as a scope deferral (Copilot advisory,
|
|
121
|
+
// app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
|
|
122
|
+
// would otherwise be blocked from converging.
|
|
123
|
+
assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
|
|
124
|
+
assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
|
|
125
|
+
assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
|
|
126
|
+
// ...but a remainder mention near genuine deferral context still defers.
|
|
127
|
+
assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
|
|
128
|
+
assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
|
|
132
|
+
assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
|
|
133
|
+
assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
|
|
134
|
+
assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
|
|
135
|
+
assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
|
|
136
|
+
assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
|
|
137
|
+
assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
|
|
138
|
+
// A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
|
|
139
|
+
assert(
|
|
140
|
+
hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
|
|
141
|
+
"Deferred-to marker with a full issue URL",
|
|
142
|
+
);
|
|
143
|
+
assert(
|
|
144
|
+
hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
|
|
145
|
+
"Follow-up marker with a full issue URL",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Scope-integrity gate for the review-convergence loop (issue #313).
|
|
2
|
+
//
|
|
3
|
+
// A parity slice can be silently under-delivered: an agent legitimately splits a large slice, ships
|
|
4
|
+
// one half, but then (a) uses a `Closes #N` closing keyword on an issue whose stated scope was
|
|
5
|
+
// broader than what shipped, and (b) records the deferred remainder only in PR/commit prose (a
|
|
6
|
+
// `## Scope` section) rather than as a filed, tracked issue. The parent then reads as fully done —
|
|
7
|
+
// `gh issue list` shows nothing outstanding — and downstream consumers trust "issue closed =
|
|
8
|
+
// capability present". This is exactly how Magikcraft/nano-bpm#631 → PR #863 (`## Scope` deferral,
|
|
9
|
+
// `Closes #631`, no follow-up) lost the deferred half until a human re-filed it by hand as #872.
|
|
10
|
+
//
|
|
11
|
+
// This pure router encodes the two guards proposed in #313, evaluated over the PR description body
|
|
12
|
+
// so the deterministic converge-gate (`workers/converge-gate`) can block a partial delivery from
|
|
13
|
+
// closing a broader-scoped parent, escalating to the human `wait-answer` task instead of merging:
|
|
14
|
+
//
|
|
15
|
+
// 1. Closing-keyword integrity — a PR may only carry `Closes/Fixes/Resolves #N` when it delivers
|
|
16
|
+
// #N's full stated scope. When the same body ALSO defers scope (a `## Scope` section /
|
|
17
|
+
// "deferred" / "out of scope" / "remains"), the closing keyword is flagged: the PR must instead
|
|
18
|
+
// use a non-closing ref (`Refs #N` / `Part of #N`) and leave #N open (or convert #N into a
|
|
19
|
+
// tracking issue).
|
|
20
|
+
// 2. Deferred ⇒ filed issue, not prose — any PR that defers part of its scope must LINK a filed
|
|
21
|
+
// follow-up issue for the remainder (`Deferred-to: #N` / `Tracked-in: #N` / `Follow-up: #N`). A
|
|
22
|
+
// deferral that exists only in commit/ADR/PR text is a drift surface (invisible, unclaimable
|
|
23
|
+
// work); mirror the repo's "no drift surfaces" rule, applied to scope.
|
|
24
|
+
//
|
|
25
|
+
// Both guards fire only when the body actually DEFERS scope, so a full-scope `Closes #N` PR with no
|
|
26
|
+
// deferral prose passes untouched.
|
|
27
|
+
|
|
28
|
+
export interface ScopeGuardInput {
|
|
29
|
+
/** The PR description body (the text `gh pr create --body` set). */
|
|
30
|
+
prBody: string | null | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScopeGuardResult {
|
|
34
|
+
scopeBlocked: boolean;
|
|
35
|
+
scopeBlockReason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GitHub's closing keywords (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved)
|
|
39
|
+
// followed by an issue ref: a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL. The
|
|
40
|
+
// keyword and ref may be separated by whitespace and/or a colon (`Closes: #1`, `Closes #1`).
|
|
41
|
+
const CLOSING_KEYWORD =
|
|
42
|
+
/\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s:]+(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/gi;
|
|
43
|
+
|
|
44
|
+
// A body DEFERS scope when it carries a `## Scope` (any heading level) section OR names a deferral in
|
|
45
|
+
// prose. A bare `remain*`/`remainder` is deliberately NOT enough on its own — normal "all done"
|
|
46
|
+
// phrasing ("No issues remain.", "no failing tests remaining") uses it without deferring any scope,
|
|
47
|
+
// and flagging that would block a full-scope PR from converging. A remainder mention only defers when
|
|
48
|
+
// a deferral-context term (scope / follow-up / later / to-do / tracking) sits near it; the incident's
|
|
49
|
+
// honest deferral read "…remain the deferred refinement", which the explicit `defer*` branch catches.
|
|
50
|
+
const DEFERRAL_HEADING = /^#{1,6}\s+scope\b/im;
|
|
51
|
+
const DEFERRAL_PHRASE = /\bdefer(?:s|red|ral|ring)?\b|\bout[- ]of[- ]scope\b/i;
|
|
52
|
+
const REMAINDER_WORD = /\bremain(?:s|der|ing)?\b/gi;
|
|
53
|
+
const REMAINDER_CONTEXT = /\b(?:scope|follow[- ]?ups?|later|to[- ]?dos?|track(?:s|ed|ing)?|next[- ]steps?)\b/i;
|
|
54
|
+
const REMAINDER_WINDOW = 48;
|
|
55
|
+
|
|
56
|
+
// Whether any `remain*` mention sits within a short window of a deferral-context term.
|
|
57
|
+
function remainderDefersScope(text: string): boolean {
|
|
58
|
+
for (const m of text.matchAll(REMAINDER_WORD)) {
|
|
59
|
+
const idx = m.index ?? 0;
|
|
60
|
+
const window = text.slice(Math.max(0, idx - REMAINDER_WINDOW), idx + m[0].length + REMAINDER_WINDOW);
|
|
61
|
+
if (REMAINDER_CONTEXT.test(window)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A FILED follow-up issue link for the deferred remainder: an explicit tracking marker followed by
|
|
69
|
+
// an issue ref — a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL (matching the
|
|
70
|
+
// closing-keyword parser, so an explicit `https://github.com/<owner>/<repo>/issues/<n>` link counts
|
|
71
|
+
// as a tracked follow-up rather than being mistaken for untracked prose). This is the
|
|
72
|
+
// machine-checkable contract feature.md asks split slices to emit.
|
|
73
|
+
const FOLLOWUP_MARKER =
|
|
74
|
+
/\b(?:deferred[- ]to|tracked[- ]in|tracking issue|follow[- ]?ups?(?:\s+issue)?)\b[\s:]*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/i;
|
|
75
|
+
|
|
76
|
+
/** The distinct issue refs a body closes via a GitHub closing keyword, in first-seen order. */
|
|
77
|
+
export function findClosingKeywordRefs(body: string | null | undefined): string[] {
|
|
78
|
+
const text = body ?? "";
|
|
79
|
+
const refs: string[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
for (const m of text.matchAll(CLOSING_KEYWORD)) {
|
|
82
|
+
const ref = m[0].slice(m[1].length).replace(/^[\s:]+/, "").trim();
|
|
83
|
+
if (!seen.has(ref)) {
|
|
84
|
+
seen.add(ref);
|
|
85
|
+
refs.push(ref);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether the body defers part of its scope (a `## Scope` section or a deferral phrase). */
|
|
92
|
+
export function hasDeferralMarker(body: string | null | undefined): boolean {
|
|
93
|
+
const text = body ?? "";
|
|
94
|
+
return DEFERRAL_HEADING.test(text) || DEFERRAL_PHRASE.test(text) || remainderDefersScope(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the body links a filed follow-up issue for the deferred remainder. */
|
|
98
|
+
export function hasFollowupIssueRef(body: string | null | undefined): boolean {
|
|
99
|
+
return FOLLOWUP_MARKER.test(body ?? "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Decide whether a PR's scope framing is safe to converge/merge. Pure; the worker feeds it the
|
|
103
|
+
* live PR body and fails CLOSED (blocks) when that body cannot be read. */
|
|
104
|
+
export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
|
|
105
|
+
const body = input.prBody ?? "";
|
|
106
|
+
const defers = hasDeferralMarker(body);
|
|
107
|
+
const reasons: string[] = [];
|
|
108
|
+
|
|
109
|
+
if (defers) {
|
|
110
|
+
const closing = findClosingKeywordRefs(body);
|
|
111
|
+
if (closing.length > 0) {
|
|
112
|
+
const noun = closing.length === 1 ? "issue" : "issues";
|
|
113
|
+
reasons.push(
|
|
114
|
+
`this PR defers part of its scope yet closing-keywords ${noun} ${closing.join(", ")} — a partial delivery must not close a broader-scoped issue; use a non-closing ref (Refs #N / Part of #N) and leave it open (or convert it into a tracking issue)`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!hasFollowupIssueRef(body)) {
|
|
118
|
+
reasons.push(
|
|
119
|
+
"this PR defers part of its scope but links no filed follow-up issue for the remainder — file a tracking issue for each deferred item and link it (Deferred-to: #N / Tracked-in: #N / Follow-up: #N) so the remainder is tracked, not left in PR prose",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (reasons.length === 0) {
|
|
125
|
+
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
scopeBlocked: true,
|
|
129
|
+
scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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",
|
|
@@ -80,9 +80,38 @@ against the wrong base will not be merged into the epic.
|
|
|
80
80
|
3. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
81
81
|
4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
|
82
82
|
branch, and open a pull request with `gh pr create` describing the slice and
|
|
83
|
-
linking the parent issue (`Depends-on:`/`Closes` as appropriate
|
|
83
|
+
linking the parent issue (`Depends-on:`/`Closes` as appropriate — but read the
|
|
84
|
+
scope-split rule below before you reach for `Closes`).
|
|
84
85
|
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
85
86
|
|
|
87
|
+
## Closing keywords vs. scope splits — don't close a broader-scoped parent
|
|
88
|
+
|
|
89
|
+
The convergence loop runs a deterministic **scope-integrity gate** on your PR
|
|
90
|
+
before it can merge (`workers/converge-gate` → `app/scopeGuard.ts`). It exists
|
|
91
|
+
because a parity slice was once silently under-delivered: an agent shipped one
|
|
92
|
+
half, documented the deferred remainder honestly in a `## Scope` section, yet
|
|
93
|
+
still `Closes #N`'d the broader parent and filed **no** follow-up. The issue read
|
|
94
|
+
as done, `gh issue list` showed nothing outstanding, and a downstream consumer was
|
|
95
|
+
blocked on exactly the deferred half. Two rules keep that from recurring — the
|
|
96
|
+
gate **blocks and escalates to a human** if you break either:
|
|
97
|
+
|
|
98
|
+
1. **A `Closes/Fixes/Resolves #N` closing keyword means you delivered #N's FULL
|
|
99
|
+
stated scope.** If you split scope — shipping only part and deferring the rest
|
|
100
|
+
— do **not** close-keyword the parent. Use a non-closing ref instead
|
|
101
|
+
(`Refs #N` / `Part of #N`) and **leave #N open** (or convert #N into a
|
|
102
|
+
tracking/umbrella issue for the remainder). The gate flags any PR that both
|
|
103
|
+
closes #N and also contains deferral prose (a `## Scope` section, "deferred",
|
|
104
|
+
"out of scope").
|
|
105
|
+
2. **A deferred remainder must be a FILED, tracked issue — never just prose.** If
|
|
106
|
+
your PR defers part of its scope, **file a follow-up issue for each deferred
|
|
107
|
+
item** and link it in the PR body with an explicit tracking marker the gate can
|
|
108
|
+
see: `Deferred-to: #N`, `Tracked-in: #N`, or `Follow-up: #N`. A deferral that
|
|
109
|
+
lives only in commit/PR/ADR text is an invisible, unclaimable drift surface.
|
|
110
|
+
|
|
111
|
+
So: deliver the whole thing → `Closes #N`. Split it → `Refs #N`, file the
|
|
112
|
+
remainder, and link it with `Deferred-to: #<new-issue>`.
|
|
113
|
+
|
|
114
|
+
|
|
86
115
|
> **Do not request the Copilot review yourself.** When you open a *ready* PR the
|
|
87
116
|
> app enrolls it into the review-convergence loop and requests the initial
|
|
88
117
|
> Copilot review for you. In particular, **never escalate because Copilot is
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
// A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
|
|
12
12
|
// the human `wait-answer` escalation (recoverable), never a hard wedge.
|
|
13
13
|
//
|
|
14
|
+
// It ALSO enforces the scope-integrity guards (#313) over the PR description, blocking handoff when
|
|
15
|
+
// the PR under-delivers a broader-scoped parent:
|
|
16
|
+
// • a partial delivery that `Closes/Fixes/Resolves #N` while ALSO deferring scope (a `## Scope`
|
|
17
|
+
// section / "deferred" / "out of scope"), or
|
|
18
|
+
// • a deferral recorded only in PR prose with no filed follow-up issue linked for the remainder.
|
|
19
|
+
// This is the enforcement backstop for the Magikcraft/nano-bpm#631 → PR #863 (`Closes #631`, `##
|
|
20
|
+
// Scope` deferral, no follow-up → re-filed by hand as #872) failure class. See app/scopeGuard.ts.
|
|
21
|
+
//
|
|
14
22
|
// It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
|
|
15
23
|
// letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
|
|
16
24
|
// merge-gating check must escalate-on-uncertainty so #770 cannot recur.
|
|
@@ -18,11 +26,13 @@ import type { AppJobHandler } from "@nanobpm/urban";
|
|
|
18
26
|
import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
|
|
19
27
|
import {
|
|
20
28
|
fetchLatestCopilotReviewBody,
|
|
29
|
+
fetchPrMeta,
|
|
21
30
|
fetchReviewThreads,
|
|
22
31
|
parseAckedAdvisories,
|
|
23
32
|
parseSuppressedAdvisories,
|
|
24
33
|
type ReviewThread,
|
|
25
34
|
} from "../../app/github.ts";
|
|
35
|
+
import { evaluateScopeGuard } from "../../app/scopeGuard.ts";
|
|
26
36
|
import { parsePr } from "../../app/service.ts";
|
|
27
37
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
28
38
|
|
|
@@ -37,20 +47,31 @@ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewTh
|
|
|
37
47
|
// Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
|
|
38
48
|
// `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
|
|
39
49
|
export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
50
|
+
// Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
|
|
51
|
+
// `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
|
|
52
|
+
export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
40
53
|
|
|
41
54
|
const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
|
|
42
55
|
fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
43
56
|
const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
|
|
44
57
|
fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
58
|
+
const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
|
|
59
|
+
const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
60
|
+
return meta ? meta.body : null;
|
|
61
|
+
};
|
|
45
62
|
|
|
46
63
|
const BLOCK_UNVERIFIABLE =
|
|
47
64
|
"Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
|
|
48
65
|
|
|
66
|
+
const BLOCK_UNVERIFIABLE_BODY =
|
|
67
|
+
"Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
|
|
68
|
+
|
|
49
69
|
/** Build the handler with injectable GitHub readers. The default export binds the real readers;
|
|
50
70
|
* tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
|
|
51
71
|
export function makeHandler(deps: {
|
|
52
72
|
readThreads: ThreadsReader;
|
|
53
73
|
readReviewBody: ReviewBodyReader;
|
|
74
|
+
readPrBody: PrBodyReader;
|
|
54
75
|
}): AppJobHandler<In, Out> {
|
|
55
76
|
return async (job) => {
|
|
56
77
|
const { prKey, repo, prNumber } = job.variables;
|
|
@@ -64,6 +85,7 @@ export function makeHandler(deps: {
|
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
let result: ConvergeGateResult;
|
|
88
|
+
let scopeReason: string;
|
|
67
89
|
try {
|
|
68
90
|
const threads = await deps.readThreads(ghRepo, ghNumber);
|
|
69
91
|
// A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
|
|
@@ -87,9 +109,30 @@ export function makeHandler(deps: {
|
|
|
87
109
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
// The scope-integrity guard (#313) reads/parses the PR description in its OWN try — a transport
|
|
113
|
+
// or parse failure here is a scope read failure, so it must surface BLOCK_UNVERIFIABLE_BODY, not
|
|
114
|
+
// the review-comment BLOCK_UNVERIFIABLE above. Sharing one catch would mislabel a description
|
|
115
|
+
// read failure as a review-thread verification failure and point the human escalation at the
|
|
116
|
+
// wrong place.
|
|
117
|
+
try {
|
|
118
|
+
// The PR description drives the scope-integrity guard (#313). A null read is unverifiable —
|
|
119
|
+
// fail closed with a scope-specific reason. (An empty STRING is a verified empty description:
|
|
120
|
+
// no closing keyword, no deferral, so the scope guard passes.)
|
|
121
|
+
const prBody = await deps.readPrBody(ghRepo, ghNumber);
|
|
122
|
+
if (prBody === null) {
|
|
123
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
124
|
+
}
|
|
125
|
+
scopeReason = evaluateScopeGuard({ prBody }).scopeBlockReason;
|
|
126
|
+
} catch {
|
|
127
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
|
|
131
|
+
// or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
|
|
132
|
+
const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
|
|
90
133
|
return {
|
|
91
|
-
convergeBlocked: result.convergeBlocked,
|
|
92
|
-
convergeBlockReason:
|
|
134
|
+
convergeBlocked: result.convergeBlocked || scopeReason !== "",
|
|
135
|
+
convergeBlockReason: reason,
|
|
93
136
|
};
|
|
94
137
|
};
|
|
95
138
|
}
|
|
@@ -97,5 +140,6 @@ export function makeHandler(deps: {
|
|
|
97
140
|
const handler = makeHandler({
|
|
98
141
|
readThreads: defaultReadThreads,
|
|
99
142
|
readReviewBody: defaultReadReviewBody,
|
|
143
|
+
readPrBody: defaultReadPrBody,
|
|
100
144
|
});
|
|
101
145
|
export default handler;
|